マルチテナント型 SaaS アプリケーションをセキュアにする

Spring Security と Apache Directory Server による認証と承認

SaaS (Software as a Service) アプリケーションはマルチテナント型であるため、セキュリティーが重大な関心事項です。この記事では、オープンソースの Spring Security フレームワークと Apache Directory Server とを組み合わせることによってマルチテナント型 Java™ アプリケーションをセキュアにするための、実行可能で現実的な手法を紹介します。著者らは、マルチテナント型 Web アプリケーションの例を使ってこの手法を説明します。

Massimiliano Parlione, Solutions Architect, WSO2 Inc

Massimiliano ParlioneMassimiliano (Max) Parlione はアイルランドにある IBM の Dublin Innovation Center に勤務するソフトウェア・アーキテクトで、マイクロファイナンスの分野のプロジェクトに携わっています。彼は 1995年に University of L'Aquila でコンピューター・サイエンスの優等学位 (Laurea) を取得し、また 2000年に University La Sapienza of Rome でコンピューター・エンジニアリングの博士号を取得しています。彼は IBM の Redbooks「Introducing IBM Tivoli Monitoring for Web Infrastructure」と「IBM Tivoli Monitoring Version 5.1.1 Creating Resource Models and Providers」の共著者でもあります。



Chico Charlesworth, Senior Java Developer, WSO2 Inc

Chico CharlesworthChico Charlesworth は 8 年以上の開発経験を持つシニア Java ソフトウェア開発者です。彼は 2000年にイギリスの Staffordshire University をコンピューター・サイエンスの優等学位で卒業後、主にエンタープライズ Java 技術に関して、テレコミュニケーションや電子課金、グリーン技術、マイクロファイナンスなどの業界を中心に業務を行ってきています。彼が主に関心を持っているのは、Java EE、オープンソース、ソフトウェア・アーキテクチャーなどです。



2008年 9月 30日

エンタープライズでの SaaS

SaaS は最近、非常によく使われるようになってきており、迅速かつコストをかけずにビジネスのニーズに応えるために、SasS のようなオンデマンドのソリューションを採用する企業が次第に増加しています。Forrester Research 社による 2007年の調査では、大企業の 16 パーセント、また中小企業の 15 パーセントが SaaS を使用しています。前年に比べるとそれぞれ 33 パーセント、50 パーセントの増加になっています (「参考文献」を参照)。実際、Saugatuck Technology 社の調査資料「Saugatuck Technology, May 2008」によれば、「2012年までに、従業員 100 人を超える企業の 70 パーセント以上が少なくとも 1 つの SaaS (Software as a Service) アプリケーションを利用していると予想される」とのことです。

SaaS を利用すると、サービス・プロバイダーはホストされた形でソフトウェア・アプリケーションを提供することができるため (また場合によってはそれまで未開拓であった市場にも提供することができるため)、スケールメリットを生かすことができます。SaaS ソリューションはマルチテナント型の特性を持っていますが、その主なメリットはサービス・プロバイダーが複数のクライアント組織にサービスを提供できることです (「参考文献」を参照)。多くのユーザーが同じリソースを共有する SaaS アプリケーションをセキュアにするための最善の方法は、データと構成とを (テナント ID に基づいて) 論理的に区分することであり、そうすることによってマルチテナント型での安全性を保証することができます。

この記事では、効果的で基本的な防御策を実装することによって Java ベースのマルチテナント型 SaaS アプリケーションをセキュアにする方法を説明します。ここで紹介するソリューションでは、オープンソースのセキュリティー・フレームワークとして実績のある Spring Securityと、LDAP (Lightweight Directory Access Protocol) v3 準拠で Java ベースの一般的なオープンソース・サーバーとを組み合わせて使用します。またこのソリューションは、Apache Tomcat または Apache Geronimo 上にデプロイ可能なサンプル Java Web アプリケーションとして入手することができます。

この記事では SaaS モデル内での認証と承認のメカニズムに焦点を当てます。SaaS のセキュリティーに関する他の概念や手法 (データのプライバシーと隔離、法的制限、監査、暗号化など) は、この記事では取りあげません。

マルチテナント型の SaaS アプリケーションでの認証と承認

認証と承認の 2 つは、実際のアプリケーションにとって最も重要なセキュリティーの懸念事項です。

  • 認証は、ある人 (あるいは別のアプリケーションやスマート・カードなど) がアプリケーションに接続する際に、その人が確かに本人であるかどうかをアプリケーションが検証するプロセスです。
  • 承認はシステムにおけるユーザーの権限とパーミッションを定義します。ユーザーが認証された後、そのユーザーがそのシステムに対してどのような権限を持つかを、承認によって決定します。従って承認は、通常は認証の後に行われます。

SaaS アプリケーションでの認証と承認はかなり複雑です。SaaS ソリューションをセキュアにするためのベースとなる認証と承認のインフラ設計には、集中型とフェデレーション型という 2 つの方式があります。この記事で提案するソリューションでは、集中型の認証システム (LDAP サーバー) を使いますが、集中型の認証システムであっても、分割あるいは複製が可能な情報を保存する分散ディレクトリーをサポートできないわけではありません。もう一方の、フェデレーテッド ID 管理と呼ばれる非集中型手法については、この記事では検討しません。フェデレーテッド ID 管理では、SaaS の領域でのセキュリティーに関して数多くの新たな難題が持ち上がっています。(典型的な使用事例での問題には、クロスドメインでの Web ベースのシングル・サインオン、ユーザー・アカウントに対するクロスドメインでのプロビジョニング、クロスドメインでの権限の管理、クロスドメインでのユーザー属性交換などがあります。詳細については「参考文献」に挙げた「Meeting the SaaS Security Challenge」を参照してください。)


Spring Security の紹介

Acegi よりも強力な Spring Security

Spring Security は 2003年に Spring のための Acegi Security System として開始されました。そして 2007年の終わり頃に Spring の正式なプロジェクトとなり、Spring Security と名前が変更されました。Acegi ではなく Spring Security を使うように推奨される理由は以下のとおりです。

  • Spring Security には Acegi が持つメリットがすべて備わっていると同時に、それ以外のメリットもあります。
  • Spring Security は Spring 2.0 構成用のカスタムの名前空間を活用しています。
  • Spring Security ではセキュリティーに関する複雑な詳細事項は XML による単純な構成の背後に隠されていて、意識する必要がなくなっています。
  • Spring Security には自動構成機能が含まれています。

Java EE (Java Enterprise Edition) 5 のセキュリティー・メカニズムでは、どのタイプの認証機構 (基本認証、フォーム認証、ダイジェスト認証、クライアント証明書による認証など) においても、テナント ID などのカスタムの属性を、デフォルトではサポートしていません。マルチテナント型に対応するためにはカスタムのソリューションを実装する必要があります。この記事では、そうしたソリューションを Spring Security フレームワークを使って作成する方法を説明します。

Spring Security にはセキュリティーのための包括的なソリューションが用意されているため、Java EE アプリケーションのセキュリティーを実現するための開発を大幅に単純化することができます。Spring Security はまた、さまざまな認証モデルをプラグインできるようにかなり上位のレベルで抽象化されている一方、多様な承認機能もサポートしています。その上、さまざまなアプリケーション・サーバーにポーティング可能な高い移植性を備えています。Spring Security の特徴をいくつか挙げると次のようになります。

  • 宣言型のセキュリティー
  • 認証と承認のためのさまざまなメカニズムのサポート (基本認証、フォーム認証、ダイジェスト認証、JDBC、LDAP など)
  • メソッド・レベルのセキュリティーと、JSR-250 によるセキュリティーに関するアノテーションのサポート
  • シングル・サインオンのサポート
  • コンテナー統合のサポート
  • 匿名セッション、並行セッション、remember-me 機能、channel-enforcement 機能等のサポート

この記事では Spring Security を LDAP と直接統合する方法に焦点を当てます。他のデプロイメント・シナリオとしては、JAAS (Java Authentication and Authorization Service) や、Spring Security フレームワークで提供されるコンテナー・アダプターを使ったコンテナー管理による認証といった別の手段が考えられます。


LDAP と Apache Directory の概要

企業においてユーザーやロールを管理するための一般的な方法として LDAP サーバーが使われます。LDAP のソリューションには、オープンソースのものや商用のものなど、いくつかの選択肢があります (「参考文献」を参照)。LDAP、つまり Apache Directory Server をよく知らない人のために、LDAP の概要を簡単に説明しましょう。

LDAP の要点

LDAP は基本的にデータベースです。しかし LDAP には、単なるデータ以上の内容を記述する、属性ベースの情報が含まれることがよくあります。この LDAP ディレクトリー内の情報は、たいていの場合書き込まれる頻度よりも読み取られる頻度の方が高いため、LDAP は読み取りを最適化するように設計されてきました。LDAP の最も一般的な例が電話帳です (電話帳では、それぞれの人に住所と電話番号が書き込まれています)。

認証と承認のためのソースとしての LDAP には、リレーショナル・データベース管理システムに比べ、次のようなメリットがあります。

  • ワイヤー・プロトコルであり、ドライバーが不要
  • スキーマが柔軟
  • ID 中心の動作
  • 認証、承認、監査
  • グループ
  • セキュリティー (パスワード、証明書)

Apache Directory Server の概要

Apache Directory Server は組み込み可能で拡張可能な、標準に準拠したオープンソースの LDAP サーバーであり、すべて Java 言語で作成されています。この記事のソリューションで Apache Directory Server を選んだ理由は、単純であり、またピュア Java で実装されているためです。実際のアプリケーションでは、どの LDAP ソリューションがビジネスと技術の要件に対して最適か、適切な評価を元に判断する必要があります。

Apache Directory プロジェクトで提供しているものは、LDAP v3 準拠と認定された Apache Directory Server と、Eclipse ベースの一連のディレクトリー・ツールである Apache Directory Studio です。


マルチテナント型の環境で Spring Security と Apache Directory Server とを統合する

通常の環境では、Spring Security と LDAP サーバーとを統合する作業は簡単です。マルチテナント型環境でも、この 2 つを統合する作業は比較的簡単ですが、通常よりも少し余分な作業が必要です。ここではまず、Apache Directory Server の中にマルチテナント型のユーザー・レジストリーを作成する方法を説明し、次に動的 LDAP ルーティング・ソリューションによってマルチテナント型のセキュリティー・ソリューションを効果的に実現する方法を説明します。

Apache Directory Server のマルチテナント型ユーザー・レジストリー

このセクションではマルチテナント型ユーザー・レジストリーの一例を説明します。このレジストリーは tenant1 と tenant2 という 2 つのセキュリティー・レルムで構成され、それぞれのレルムは administrator (管理者) と guest (ゲスト) という 2 つの異なるユーザー・グループを持っています。それぞれのグループにはさまざまなユーザーがリンクされており、それらのユーザーは指定されたロールを共有し、またそのユーザーが属するグループのセキュリティー・レルムに属しています。

ユーザーのクレデンシャルは、そのレルムに応じて Apache Directory Server のユーザー・レジストリーの対応するサブツリーの下に保存されます (図 1)。セキュリティー・レルムが異なると、割り当てられる LDAP サフィックスも異なるものになります。例えば、tenant1 のベース DN (Distinguished Name: 識別名) は [dc=tenant1, dc=com] で、tenant2 のベース DN は [dc=tenant2, dc=com] になるといった具合です。

図 1. マルチテナント型 LDAP ユーザー・レジストリーの例
マルチテナント型 LDAP ユーザー・レジストリーの例

次に、各グループ (実際にはユーザー・ロールと考えることができます) は、それぞれのセキュリティー・レルムに割り当てられます。例えば [cn=adm, ou=groups] と [cn=gst, ou=groups] という 2 つのグループは、それぞれ管理者ロールとゲスト・ロールと考えることができ、いずれも [dc=tenant1, dc=com] というベース DN を持つtenant1 というセキュリティー・レルムに属すことになります。

そして、ユーザー・エントリーは、指定されたセキュリティー・レルムの下にある特定のグループに関連付けられます。例えば、[uid=tenant1admin, ou=people] というユーザーは [cn=adm, ou=groups] という管理者ロールを割り当てられ、[dc=tenant1, dc=com] というセキュリティー・レルムに属すことになります。

図 1 の各セキュリティー・レルムに対して、Apache Directory Server の server.xml 構成ファイル(Apache Directory Server のインストール・ディレクトリーの /instances/default/conf/server.xml) の中に新しいパーティションとセキュリティー・コンテキスト・エントリーを作成する必要があります (リスト 1)。

リスト 1. Apache Directory Server の server.xml ファイル
<?xml version="1.0" encoding="UTF-8"?>
<spring:beans xmlns:spring="http://xbean.apache.org/schemas/spring/1.0" 
  xmlns:s="http://www.springframework.org/schema/beans"
  xmlns="http://apacheds.org/config/1.0">
...
  <jdbmPartition id="tenant1" cacheSize="100" suffix="dc=tenant1,dc=com"
                          optimizerEnabled="true" syncOnWrite="true">
    <indexedAttributes>
      <jdbmIndex attributeId="1.3.6.1.4.1.18060.0.4.1.2.1" cacheSize="100"/>
      <jdbmIndex attributeId="1.3.6.1.4.1.18060.0.4.1.2.2" cacheSize="100"/>
      <jdbmIndex attributeId="1.3.6.1.4.1.18060.0.4.1.2.3" cacheSize="100"/>
      <jdbmIndex attributeId="1.3.6.1.4.1.18060.0.4.1.2.4" cacheSize="100"/>
      <jdbmIndex attributeId="1.3.6.1.4.1.18060.0.4.1.2.5" cacheSize="10"/>
      <jdbmIndex attributeId="1.3.6.1.4.1.18060.0.4.1.2.6" cacheSize="10"/>
      <jdbmIndex attributeId="1.3.6.1.4.1.18060.0.4.1.2.7" cacheSize="10"/>
      <jdbmIndex attributeId="dc" cacheSize="100"/>
      <jdbmIndex attributeId="ou" cacheSize="100"/>
      <jdbmIndex attributeId="krb5PrincipalName" cacheSize="100"/>
      <jdbmIndex attributeId="uid" cacheSize="100"/>
      <jdbmIndex attributeId="objectClass" cacheSize="100"/>
    </indexedAttributes>
    <contextEntry>#tenant1ContextEntry</contextEntry>
  </jdbmPartition>
...
  <spring:bean id="tenant1ContextEntry" 
class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
    <spring:property name="targetObject">
      <spring:ref local='directoryService'/>
    </spring:property>
    <spring:property name="targetMethod">
      <spring:value>newEntry</spring:value>
    </spring:property>
    <spring:property name="arguments">
      <spring:list>
        <spring:value xmlns="http://www.springframework.org/schema/beans">
          objectClass: top
          objectClass: domain
          objectClass: extensibleObject
          dc: tenant1
        </spring:value>
        <spring:value>dc=tenant1,dc=com</spring:value>
      </spring:list>
    </spring:property>
  </spring:bean>
...
</spring:beans>

また tenant1 の場合と同じように、tenant2 という新しいパーティションとセキュリティー・コンテキスト・エントリーも追加する必要があります。server.xml のサンプルはサンプル Web アプリケーションの中にあります。

セキュリティー・レルムを作成できたら、リスト 2 の LDIF (LDAP Date Interchange Format) ファイルのような内容を、対応するセキュリティー・レルムの中にインポートします。そのためには Apache Directory Studio Eclipse プラグイン (「参考文献」を参照) の中にある LDIF インポート・ウィザードを使います。LDIF ファイルのサンプルはサンプル Web アプリケーションの中にあります。

リスト 2. tenant1_users.ldif
dn: ou=groups,dc=tenant1,dc=com
objectclass: top
objectclass: organizationalUnit
ou: groups

dn: ou=people,dc=tenant1,dc=com
objectclass: top
objectclass: organizationalUnit
ou: people

dn: uid=tenant1admin,ou=people,dc=tenant1,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: tenant1admin
sn: tenant1admin
uid: tenant1admin
userPassword: tenant1admin

dn: uid=tenant1guest,ou=people,dc=tenant1,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: tenant1guest
sn: tenant1guest
uid: tenant1guest
userPassword: tenant1guest

dn: cn=gst,ou=groups,dc=tenant1,dc=com
objectclass: top
objectclass: groupOfNames
cn: gst
member: uid=tenant1guest,ou=people,dc=tenant1,dc=com

dn: cn=adm,ou=groups,dc=tenant1,dc=com
objectclass: top
objectclass: groupOfNames
cn: adm
member: uid=tenant1admin,ou=people,dc=tenant1,dc=com

Spring Security による動的 LDAP ルーティング

動的 LDAP ルーティングを行うための基本として、実行時に参照キーに基づいて LDAP セキュリティー・コンテキストを動的に選択できる機能が必要です (図 2)。これはマルチテナント型環境の場合、テナントの ID からオンザフライで得られる LDAP ソースに対して認証と承認を行う必要があるということです。

図 2. マルチテナント型の動的 LDAP ルーティング
マルチテナント型の動的 LDAP ルーティング

動的 LDAP ルーティングは Spring に元々用意されているわけではありません。そこで私達は独自に作成することにしました。これを作成するに当たっては、同様のソリューションであり、データ・ソース用に調整された、Spring の AbstractRoutingDataSource (「参考文献」を参照) の考え方を採用しています。

私達が作成した動的 LDAP ルーティングの手法は、次の 3 つの主要なクラスの中にカプセル化されています。

  • AbstractRoutingSpringSecurityContextSource (リスト 3) は Spring の LdapContextSource クラスをベースにした抽象実装です。この実装は「実際の」セキュリティー・コンテキスト・ソースのコレクション (targetSpringSecurityContextSources を参照) への参照を保持しており、この実装の目的は、決定された参照キー (getResolvedContextSource() を参照) に基づいて、ターゲットとなるさまざまなセキュリティー・コンテキスト・ソースの 1 つに対して呼び出しをルーティングすることにあります。
    リスト 3. AbstractRoutingSpringSecurityContextSource.java
    public abstract class AbstractRoutingSpringSecurityContextSource<T 
        extends Serializable> extends LdapContextSource 
        implements SpringSecurityContextSource, InitializingBean {
    
      private Map<T, DefaultSpringSecurityContextSource>
        targetSpringSecurityContextSources;
    
      /** Determine the current lookup key. This will typically be
          implemented to check a thread-bound context. */
      protected abstract T determineCurrentLookupKey();
    
      /** Determine the 'real' security context source dynamically
          at runtime based upon a lookup key. */
      protected DefaultSpringSecurityContextSource getResolvedContextSource() {
        T lookupKey = determineCurrentLookupKey();
        DefaultSpringSecurityContextSource springSecurityContextSource =
          this.targetSpringSecurityContextSources.get(lookupKey);
        if (springSecurityContextSource == null) {
            throw new IllegalStateException(
              "Cannot determine target SpringSecurityContextSource for lookup key [" +
               lookupKey + "]");
        }
        return springSecurityContextSource;
      }
    
      public void setTargetSpringSecurityContextSources(
        Map<T, DefaultSpringSecurityContextSource> targetSpringSecurityContextSources) {
        this.targetSpringSecurityContextSources = targetSpringSecurityContextSources;
      }
    
      public void afterPropertiesSet() throws Exception {
        if (this.targetSpringSecurityContextSources == null) {
          throw new IllegalArgumentException(
            "targetSpringSecurityContextSources is required");
        }
      }
    
      public DirContext getReadWriteContext(String userDn, Object credentials) {
        return this.getResolvedContextSource().getReadWriteContext(userDn, credentials);
      }
    
      @Override
      public DirContext getReadOnlyContext() {
        return this.getResolvedContextSource().getReadOnlyContext();
      }
    
      @Override
      public DirContext getReadWriteContext() {
        return this.getResolvedContextSource().getReadWriteContext();
      }
    
      @Override
      public DistinguishedName getBaseLdapPath() {
        return this.getResolvedContextSource().getBaseLdapPath();
      }
    
      @Override
      public String getBaseLdapPathAsString() {
        return this.getResolvedContextSource().getBaseLdapPathAsString();
      }
    
      @Override
      public Class getContextFactory() {
       return this.getResolvedContextSource().getContextFactory();
      }
    
      @Override
      public Class getDirObjectFactory() {
        return this.getResolvedContextSource().getDirObjectFactory();
      }
    
      @Override
      public boolean isPooled() {
        return this.getResolvedContextSource().isPooled();
      }
    
      @Override
      public AuthenticationSource getAuthenticationSource() {
        return this.getResolvedContextSource().getAuthenticationSource();
      }
    
      @Override
      public boolean isAnonymousReadOnly() {
        return this.getResolvedContextSource().isAnonymousReadOnly();
      }
    
      @Override
      public String[] getUrls() {
        return this.getResolvedContextSource().getUrls();
      }
      
    }
  • 2 番目のクラスは TenantRoutingSpringSecurityContextSource です (リスト 4)。このクラスが抽象メソッド determineCurrentLookupKey() を実装しており、これによって明確な関心の分離として論理的な境界が設けられていることに注目してください。
    リスト 4. TenantRoutingSpringSecurityContextSource.java
    public class TenantRoutingSpringSecurityContextSource<T extends Serializable>
        extends AbstractRoutingSpringSecurityContextSource<String> {
       
      @Override
      protected String determineCurrentLookupKey() {
        String lookupKey = TenantSecurityContextHolder.getTenantID();
        return lookupKey;
      }
    
    }
  • 動的 LDAP ルーティングのパズルの最後のピースが TenantSecurityContextHolder クラスです (リスト 5)。このクラスはスレッドにバインドされたコンテキストを保持し、このコンテキストにテナント ID への参照が保持されます。これによって TenantRoutingSpringSecurityContextSource クラスは実行時にそのコンテキストにアクセスすることができます。
    リスト 5. TenantSecurityContextHolder.java
    public class TenantSecurityContextHolder {
    
      private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>();
    
      public static void setTenantID(String tenantID) {
        contextHolder.set(tenantID);
      }
    
      public static String getTenantID() {
        return contextHolder.get();
      }
    
      public static void clearTenantID() {
        contextHolder.remove();
      }
    
    }

動的 LDAP ルーティングによる Web の統合

これで動的 LDAP ルーティングのための基礎は用意できましたが、Web アプリケーションの中で動的 LDAP ルーティングを実際に使用するには、もう少し統合作業を行う必要があります。そのための方法は無数にありますが、どの方法でもテナントID への参照を保持するための (スレッドにバインドされた) コンテキストを設定する必要があります。そうした方法の 1 つとして、そのタスクを行うサーブレット・フィルターを含める方法があります。

セキュリティー用のサーブレット・フィルター (リスト 6) は、ユーザーがログインするときにリクエスト・パラメーターとしてテナント ID が渡される、という前提の上で動作します。サーブレット・フィルターはそのテナント ID を Web セッションの中に保存し、認証済みのユーザーからのリクエストが後で送られて来た時にそのリクエストを取得できるようにします。

リスト 6. TenantSecurityContextFilter.java
public class TenantSecurityContextFilter implements Filter {

  private static final String
    SPRING_SECURITY_CHECK_MAPPING = "/j_spring_security_check";

  private static final String
    SPRING_SECURITY_LOGOUT_MAPPING = "/j_spring_security_logout";

  private static final String TENANT_HTTP_KEY = "tenant";

  protected final Log logger = LogFactory.getLog(this.getClass());

  private FilterConfig filterConfig;

  public void init(FilterConfig filterConfig) throws ServletException {
    this.filterConfig = filterConfig;
  }

  public void destroy() {
    this.filterConfig = null;
  }

  public void doFilter(ServletRequest request, ServletResponse response,
    FilterChain chain) throws IOException, ServletException {
    if (null == filterConfig) {
      return;
    }
    HttpServletRequest httpRequest = (HttpServletRequest) request;

    // Clear tenant security context holder, and if it's a logout
    // request then clear tenant attribute from the session
    TenantSecurityContextHolder.clearTenantID();
    if (httpRequest.getRequestURI().endsWith(SPRING_SECURITY_LOGOUT_MAPPING)) {
      httpRequest.getSession().removeAttribute(TENANT_HTTP_KEY);
    }

    // Resolve Tenant ID
    String tenantID = null;
    if (httpRequest.getRequestURI().endsWith(SPRING_SECURITY_CHECK_MAPPING)) {
      tenantID = request.getParameter(TENANT_HTTP_KEY);
      httpRequest.getSession().setAttribute(TENANT_HTTP_KEY, tenantID);
    } else {
      tenantID = (String) httpRequest.getSession().getAttribute(TENANT_HTTP_KEY);
    }

    // If found, set the Tenant ID in the security context
    if (null != tenantID) {
      TenantSecurityContextHolder.setTenantID(tenantID);
      if (logger.isInfoEnabled()) logger.info(
        "Tenant context set with Tenant ID: " + tenantID);
    }

    chain.doFilter(request, response);
  }

}

Spring Security での XML の構成

Spring の大きな強みの 1 つとして、IoC (Inversion of Control: 制御の反転) の原則が実装されており、アプリケーションの構成と依存関係の仕様を実際のアプリケーション・コードから明確に分離することができます (「参考文献」を参照)。しかし Spring に対する不満としてよく指摘されるように、その構成ファイル (通常は XML フォーマット) は冗長で扱いにくいものになりがちです。幸いなことに、Spring Security での XML の構成は、Spring 2.0 で名前空間の構成機能が導入されて以来、大幅に簡略化されています。

Spring Security での XML の構成を利用すると、Spring アプリケーションのコンテキスト・ファイルでの認証と承認に関する詳細事項の大半を定義することができます。そうした詳細事項に含まれるものとしては、LDAP 認証プロバイダーの構成や、ユーザー・ロールに基づく URL レベルの承認などがあります (ここには示しませんが、Spring Security でサポートされているメソッド・レベルのセキュリティーを利用して、きめ細かな承認を行うこともできます)。

リスト 7 に示す、アプリケーションのセキュリティー・コンテキストの XML 構成ファイルは単なる一例ですが、これを見ると、宣言型で Spring Security を構成するために必要な基本 XML コンポーネントがどのようなものかを理解できるはずです。ただしここで、カスタムで作成したマルチテナント型 LDAP ルーティングのセキュリティー・コンテキストのソースが自動的に結び付けられて、マルチテナント型の統合がシームレスに行われていることに注目してください。

リスト 7. application-context-security.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:s="http://www.springframework.org/schema/security"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans-2.0.xsd
    http://www.springframework.org/schema/security
    http://www.springframework.org/schema/security/spring-security-2.0.1.xsd">
...
 <!-- HTTP security configuration -->
 <s:http>        
        <s:intercept-url pattern="/poc/login" access="IS_AUTHENTICATED_ANONYMOUSLY"/>
        <s:intercept-url pattern="/poc/admin/**" access="ROLE_ADM" />
        <s:intercept-url pattern="/poc/**" access="ROLE_GST, ROLE_ADM" />
        
        <s:form-login login-page="/poc/login" default-target-url="/poc/home"/>
        <s:anonymous />
        <s:logout />
 </s:http>
    
 <!-- LDAP Authentication Provider -->
 <s:ldap-authentication-provider server-ref="contextSource"
        group-search-filter="member={0}" group-search-base="ou=groups"
        user-search-base="ou=people" user-search-filter="uid={0}"/>
    
 <!-- Custom Multitenant Routing Spring Security Context Source -->
 <bean id="contextSource" class=
 "poc.saas.security.core.multitenancy.context.TenantRoutingSpringSecurityContextSource">
<property name="targetSpringSecurityContextSources">
      <map>
         <entry key="Tenant1" value-ref="tenant1ContextSource"/>
         <entry key="Tenant2" value-ref="tenant2ContextSource"/>
      </map>
        </property>
 </bean>
    
 <!-- This bean points at the at the Tenant1 LDAP Server  -->
 <bean id="tenant1ContextSource"
    class="org.springframework.security.ldap.DefaultSpringSecurityContextSource">
        <constructor-arg value="ldap://localhost:10389/dc=tenant1,dc=com"/>
        <property name="userDn"><value>uid=admin,ou=system</value></property>
      <property name="password"><value>secret</value></property>
 </bean>
    
 <!-- This bean points at the at the Tenant2 LDAP Server  -->
 <bean id="tenant2ContextSource"
    class="org.springframework.security.ldap.DefaultSpringSecurityContextSource">
        <constructor-arg value="ldap://localhost:10389/dc=tenant2,dc=com"/>
        <property name="userDn"><value>uid=admin,ou=system</value></property>
      <property name="password"><value>secret</value></property>
 </bean>
...
</beans>

標準的な Java Web アプリケーションで Spring Security を有効にするためには、アプリケーションのセキュリティー・コンテキストとセキュリティー・フィルターをデプロイメント記述子ファイル web.xml の中に含める必要があります (リスト 8)。

リスト 8. web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/j2ee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.4"
xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee
        http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd">
...
 <!-- Spring context configuration files -->
 <context-param>
<param-name>contextConfigLocation</param-name>
<param-value>
...
classpath*:application-context-security.xml
</param-value>
 </context-param>
...
 <!-- Tenant Security Context Filter -->
 <filter>
<filter-name>Tenant Security Context Filter</filter-name>
<filter-class>
  poc.saas.security.core.multitenancy.web.filter.TenantSecurityContextFilter
</filter-class>
 </filter>
...
 <!-- Spring Security Filter -->
 <filter>
        <filter-name>springSecurityFilterChain</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
 </filter>
...
 <!-- Tenant Security Context Filter Mapping -->
 <filter-mapping>
<filter-name>Tenant Security Context Filter</filter-name>
<url-pattern>/*</url-pattern>
 </filter-mapping>
...
 <!-- Spring Security Filter Mapping -->
 <filter-mapping>
      <filter-name>springSecurityFilterChain</filter-name>
      <url-pattern>/*</url-pattern>
 </filter-mapping>
...
 <!-- Spring MVC web context listener -->
 <listener>
<listener-class>
  org.springframework.web.context.ContextLoaderListener
</listener-class>
 </listener>
...
</web-app>

マルチテナント型の認証を検証する

Spring が統合テストをサポートすることによって容易に行えるようになる JUnit テストをリスト 10 に示します。この統合テストを利用すると、マルチテナント型の認証プロセスが想定どおり動作するかどうかをアプリケーション・サーバーにコードをデプロイせずに検証することができます。ただしこのテストでは、認証プロバイダーとして動作するように Apache Directory Server が実行されている必要があります。このテストの主な目的は、さまざまなユーザーに対する認証をテストすることです。tenant1 と tenant2 という 2 つのセキュリティー・レルムに対して、有効なユーザーと有効ではないユーザーがいます。このテストで行われている詳細な内容はコード・リストの後に説明してあります。

リスト 10. MultiTenantAuthenticationTest.java
public class MultiTenantAuthenticationTest
    extends AbstractDependencyInjectionSpringContextTests {

  private static final String
    APPLICATION_CONTEXT_SECURITY = "classpath:application-context-security.xml";

  private FilterChain passThroughFilterChain;

  private Filter formLoginFilter;

  private Filter tenantSecurityContextFilter;

  @Override
  protected String[] getConfigLocations() {
    return new String[] {APPLICATION_CONTEXT_SECURITY};
  }

  @Override
  protected void onSetUp() throws Exception {
    // Setup mock instances
    MockServletContext servletContext = new MockServletContext("");
    servletContext.addInitParameter(ContextLoader.CONFIG_LOCATION_PARAM,
                                APPLICATION_CONTEXT_SECURITY);
    ServletContextListener contextListener = new ContextLoaderListener();
    ServletContextEvent event = new ServletContextEvent(servletContext);
    contextListener.contextInitialized(event);
    MockFilterConfig mockConfig = new MockFilterConfig(servletContext);        
        
    // Setup tenant security context filter
    tenantSecurityContextFilter = new TenantSecurityContextFilter();
    tenantSecurityContextFilter.init(mockConfig);

    // Setup Spring Security's form login filter
    formLoginFilter = 
      (Filter) this.getApplicationContext().getBean("_formLoginFilter");
    formLoginFilter.init(mockConfig);

    // Setup a pass through filter chain
    passThroughFilterChain =
      new PassThroughFilterChain(formLoginFilter, new MockFilterChain());
  }

  public void testMultiTenantAuthenticationWorksAsExpected() throws Exception {
    this.assertGivenUserCredentialsAreValid("Tenant1", "tenant1admin", "tenant1admin");
    this.assertGivenUserCredentialsAreValid("Tenant2", "tenant2admin", "tenant2admin");
    this.assertGivenUserCredentialsAreInvalid("Tenant1", "invalidUser", "wrongPassword");
    this.assertGivenUserCredentialsAreInvalid("Tenant1", "tenant1admin", "wrongPassword");
    this.assertGivenUserCredentialsAreInvalid("Tenant2", "tenant1admin", "tenant1admin");
    this.assertGivenUserCredentialsAreValid("Tenant2", "tenant2guest", "tenant2guest");
  }

  private void assertGivenUserCredentialsAreValid
    (String tenantId, String username, String password) throws Exception {
    // Authenticate valid user using the given tenant id
    Object[] result = this.performUserAuthentication(tenantId, username, password);

    // Ensure user is now authenticated and has been redirected to the home page
    assertNotNull(SecurityContextHolder.getContext().getAuthentication());
    assertEquals(username,
      SecurityContextHolder.getContext().getAuthentication().getName());                
    assertEquals("/poc/home", result[0]);
        
    System.out.println("Authentication success for user " + username + " [" +
      SecurityContextHolder.getContext().getAuthentication().getPrincipal().getClass()
      + "]");
  }

  private void assertGivenUserCredentialsAreInvalid(
    String tenantId, String username, String password) throws Exception {
    // Attempt to authenticate invalid user using the given tenant id
    Object[] result = this.performUserAuthentication(tenantId, username, password);

    // Ensure user was denied authentication and has
    // been redirected back to the login page
    assertNull(SecurityContextHolder.getContext().getAuthentication());
    assertEquals("/poc/login", result[0]);

    System.out.println("Authentication failed for user "
      + username + " [" + result[1].getClass() + "]");
  }

  private Object[] performUserAuthentication(
    String tenantId, String username, String password) throws Exception {
    // Build mock request
    MockHttpServletRequest request =
      new MockHttpServletRequest("POST", "/poc/j_spring_security_check");
    request.setParameter("tenant", tenantId);
    request.setParameter("j_username", username);
    request.setParameter("j_password", password);
        
    // Run security filter and return response URL
    MockHttpServletResponse response = new MockHttpServletResponse();
    tenantSecurityContextFilter.doFilter(request, response, passThroughFilterChain);
        
    Object[] result = 
      {response.getRedirectedUrl(), 
       request.getSession().getAttribute("SPRING_SECURITY_LAST_EXCEPTION")};
    return result;
  }

}

このテストは以下のような論理フローをシミュレートしています。

  1. 一連のモックアップ・オブジェクトと TenantSecurityContextFilteronSetUp() メソッドの中にセットアップされます。
  2. テストの実行ごとに呼び出される testMultiTenantAuthenticationWorksAsExpected() メソッドは、有効なユーザーがアクセスを許可されるかどうか、また無効なユーザーがアクセスを拒否されるかどうかを検証するために、それぞれ assertGivenUserCredentialsAreValid()assertGivenUserCredentialsAreInvalid() とを呼び出します。
  3. assertGivenUserCredentialsAreValid() メソッドは呼び出されると、指定されたユーザーの認証を試み、そのユーザーが想定どおり認証されたことを確認します。
  4. assertGivenUserCredentialsAreInvalid() メソッドは呼び出されると、指定された (無効なクレデンシャルを持つ) ユーザーの認証を試み、そのユーザーがアクセスを拒否され、ログイン・ページにリダイレクトされることを確認します。
  5. performUserAuthentication() メソッドは assertGivenUserCredentialsAreValid() メソッドまたは assertGivenUserCredentialsAreInvalid() メソッドに呼び出されると、以下に示す実際の認証プロセスを開始します。
    1. 指定されたテナント ID、ユーザー名、パスワードをリクエスト・パラメーターとして使って、先ほど作成した TenantSecurityContextFilter インスタンスに対して、擬似的な HTTP ログイン・リクエストを行います。
    2. セキュリティー・フィルターはこのログイン・リクエストをインターセプトしてテナント ID を抽出し、そのID をスレッドにバインドされたコンテキストとして TenantSecurityContextHolder の中に保存し、それからそのリクエストを passThroughFilterChain に渡します。お気づきかもしれませんが、passThroughFilterChainonSetUp() の中で、Spring Security の標準的なフォームのログイン用フィルターに対するラッパーとして動作するように設定されています。passThroughFilterChainapplication-context-security.xml の中で定義される LDAP 認証プロバイダーによって裏で作成され、LDAP コンテキスト・ソースとして TenantRoutingSpringSecurityContextSource を使うように構成されます。そのため、認証の対象となる解決されたコンテキスト・ソースを Spring Security が取得しようとすると、(AbstractRoutingSpringSecurityContextSource を継承する) TenantRoutingSpringSecurityContextSourceTenantSecurityContextHolder に保持されているテナント ID に従って適切な LDAP ソースを動的に返します。

目立つ緑色のバーで認証の成功を表示してもあまり多くの内容を伝えることはできないため、リスト 11 では、Eclipse の中で JUnit テストとして MultiTenantAuthenticationTest を実行すると生成されるコンソール出力を示しています。

リスト 11. テストの出力
Authentication success for user tenant1admin
  [org.springframework.security.userdetails.ldap.LdapUserDetailsImpl@676437]
  
Authentication success for user tenant2admin
  [org.springframework.security.userdetails.ldap.LdapUserDetailsImpl@992bae]
  
Authentication failed for user invalidUser
  [org.springframework.security.userdetails.UsernameNotFoundException]
  
Authentication failed for user tenant1admin
  [org.springframework.security.BadCredentialsException]
  
Authentication failed for user tenant1admin
  [org.springframework.security.userdetails.UsernameNotFoundException]
  
Authentication success for user tenant2guest
  [org.springframework.security.userdetails.ldap.LdapUserDetailsImpl@1f64158]

サンプル・アプリケーション

この記事では、ここまでに学んだすべての概念を説明する Web アプリケーションのサンプルを提供しています (「ダウンロード」を参照)。さらに、Spring LDAP を使って実装した、ユーザーとパスワードを管理するための機能を追加してあります。Spring LDAP は、Spring ベースの Java アプリケーションで LDAP を使用する上での一般的な基礎事項の詳細から Java 開発者を解放するために設計されたフレームワークです (「参考文献」を参照)。

このサンプル Web アプリケーションはセキュリティーを考慮して設計されていますが、それでもクロスサイト・スクリプティングやリクエスト・フォージェリー、セッション・ハイジャックなど数々の脆弱性が潜在していることを意識する必要があり、実際のエンタープライズ Web アプリケーションをセキュアにするにはこうした点を考慮する必要があります。OWASP (Open Web Application Security Project、「参考文献」を参照) では、こうしたタイプのセキュリティー・リスクに関する有用な参考資料を豊富に提供しています。

このサンプル Web アプリケーションは、Servlet API 2.5、JavaServer Pages 2.1、Spring 2.5、Spring Web MVC 2.5、そして Spring Security 2.0.1 をベースにしています。Eclipse と Apache Maven 2 を利用すると、以下のシステムにこのアプリケーションを適切にデプロイし、テストすることができます。

  • Java SE 6 上で実行される Apache Tomcat 6
  • Java SE 5 上で実行される、Tomcat 6 または Jetty 6 を組み込んだ Apache Geronimo 2.1

今すぐこのサンプル・アプリケーションをダウンロードし、実際に試してみてください。このダウンロードには、このアプリケーションを立ち上げて実行させるまでの手順を説明した readme.html ファイルが含まれています。また、このアプリケーションが実行される様子を Flash デモで見ることもできます。


まとめ

この記事では、マルチテナント型 SaaS アプリケーションのセキュリティーに関する重要な考慮事項をいくつか検証しました。そのために、Spring Security フレームワークを利用して、マルチテナント型 Apache Directory Server のユーザー・レジストリーを設定する方法と、マルチテナント型 LDAP ソースに対して認証と承認を行う方法を示しました。また、動的 LDAP ルーティング・ソリューションを利用してマルチテナント型のエコシステムの中で Spring Security と Apache Directory Server とを統合する方法も学びました。

この記事ではいくつかの技術的な選択肢を提案しましたが、SaaS に関する要件に最適な技術ソリューションはどれかを判断するために、適切な評価を行うようにお勧めします。SaaS ソリューションを構築するための他の選択肢を見つけるためには、この記事の「参考文献」を調べてください。

謝辞

この記事のレビューを行い助言をいただいた David Jencks と Paul Browne の両氏、そしてこの記事を執筆する上でご支援いただいた Kevan Miller 氏と Geronimo and WebSphere Community Edition チームの皆さんに感謝いたします。


ダウンロード

内容ファイル名サイズ
SaaS Security PoC - Example Applicationj-saas.zip47KB

参考文献

学ぶために

製品や技術を入手するために

  • Apache Directory Server をダウンロードしてください。
  • Apache Directory Studio は Eclipse の更新サイトから入手することができます。
  • LDAP 操作を単純化するための Java ライブラリー、Spring LDAP をダウンロードしてください。
  • IBM 製品の試用版をダウンロードし、DB2® や Lotus®、Rational®、Tivoli®、WebSphere® などが提供するアプリケーション開発ツールやミドルウェア製品をお試しください。

議論するために

コメント

developerWorks: サイン・イン

必須フィールドは(*)で示されます。


IBM ID が必要ですか?
IBM IDをお忘れですか?


パスワードをお忘れですか?
パスワードの変更

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


お客様が developerWorks に初めてサインインすると、お客様のプロフィールが作成されます。会社名を非表示とする選択を行わない限り、プロフィール内の情報(名前、国/地域や会社名)は公開され、投稿するコンテンツと一緒に表示されますが、いつでもこれらの情報を更新できます。

送信されたすべての情報は安全です。

ディスプレイ・ネームを選択してください



developerWorks に初めてサインインするとプロフィールが作成されますので、その際にディスプレイ・ネームを選択する必要があります。ディスプレイ・ネームは、お客様が developerWorks に投稿するコンテンツと一緒に表示されます。

ディスプレイ・ネームは、3文字から31文字の範囲で指定し、かつ developerWorks コミュニティーでユニークである必要があります。また、プライバシー上の理由でお客様の電子メール・アドレスは使用しないでください。

必須フィールドは(*)で示されます。

3文字から31文字の範囲で指定し

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


送信されたすべての情報は安全です。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Java technology
ArticleID=348823
ArticleTitle=マルチテナント型 SaaS アプリケーションをセキュアにする
publish-date=09302008