保护多承租 SaaS 应用程序

用 Spring Security 和 Apache Directory Server 进行身份验证和授权

软件即服务(Software as a Service,SaaS)应用程序多承租的性质决定了安全性是一个关键的问题。本文介绍了一个保护多承租 Java™应用程序的可行的、实用的方法,即结合使用开源 Spring Security 框架和 Apache Directory Server。作者通过一个多承租示例 Web 应用程序来展示这个方法。

Massimiliano Parlione, 解决方案架构师, EMC

Massimiliano ParlioneMassimiliano (Max) Parlione 是位于爱尔兰的 IBM 都柏林软件实验室的一名解决方案架构师,他致力于微观金融领域的项目。Massimiliano 于 1995 年 7 月获得 University of L'Aquila 的计算机科学专业(本科)荣誉学士学位,并于 2000 年 4 月获得 University La Sapienza of Rome 的计算机工程专业博士学位。他是 IBM 红皮书 “Introducing IBM Tivoli Monitoring for Web Infrastructure” 和 “IBM Tivoli Monitoring Version 5.1.1 Creating Resource Models and Providers” 的合著者。



Chico Charlesworth, 高级 Java 开发人员, EMC

Chico CharlesworthChico Charlesworth 是高级 Java 软件开发人员,有超过八年的开发经验。他于 2000 年获得英国 Staffordshire University 的计算机科学专业荣誉学士学位。毕业后,他一直致力于研究企业 Java 技术,专攻远程通讯、电子记账、绿色技术和微观金融等行业。他最感兴趣的是 Java EE、开源和软件架构。



2008 年 10 月 24 日

企业中的 SaaS

这些年 SaaS 开始繁荣起来了。越来越多的公司都转向这个随需应变的解决方案来更快地响应业务需求,降低成本。Forrester Research 于 2007 年进行的一项调查发现,有 16% 的大型企业和 15% 中小型企业都在使用 SaaS。而且每年分别以 33% 和 50% 的速度增长(参见 参考资料)。事实上,根据 Saugatuck Technology,2008 年 5 月的统计,“到 2012 年时,超过 100 个员工的商业组织会有 70% 以上已经配备了至少一个 SaaS 应用程序”。

SaaS 能够提供宿主的软件应用程序,并且可能向未开发市场领域提供服务,从而使服务供应商实现规模经济。SaaS 的 多承租的最大优点是:它允许服务供应商向多个客户机组织提供服务(参见 参考资料)。在 SaaS 应用程序中有很多用户共享相同的资源,所以保护它的最好的方法就是对数据和配置(基于承租者 ID)进行逻辑分区,从而确保多承租的安全。

本文展示了如何实现第一道有效防线来保护基于 Java 的多承租 SaaS 应用程序。该解决方案结合使用了 Spring Security(一个经久不衰的开源安全框架)和 Apache Directory Server(一个基于 Java 的流行服务器,它是开源的并且遵从 Lightweight Directory Access Protocol v3,即 LDAP v3)。本文提出的解决方案是一个 示例 Java Web 应用程序,它既可以部署到 Apache Tomcat,也可以部署到 Apache Geronimo。

本文重点介绍 SaaS 模型内的身份验证和授权机制。其他有关 SaaS 安全性的概念和技术 —比如数据保密与隔离、法规、审计和密码等,超出了本文的范围。

多承租的 SaaS 应用程序中的身份验证与授权

身份验证和授权是现实应用程序的安全性概念中主要的两个:

  • 身份验证允许一个应用程序在连接时验证一个人(或一个应用程序、智能卡等)是否与它声明的一样。
  • 授权定义一个用户在一个系统上的权利与权限。用户身份验证通过之后,授权会决定该用户在系统上有权做什么。因此,授权应该发生在身份验证之后。

身份验证和授权在 SaaS 应用程序中很复杂。在一个安全性 SaaS 解决方案中,底层的身份验证和授权基础设施有两种设计方法:集中式或联邦式。本文提出的解决方案使用集中式身份验证系统(LDAP 服务器)。集中式的身份验证系统并不排除支持分布式目录的可能性,分布式目录储存了可以分区和复制的信息。本文不考虑采用另一种分散式处理方法,即 联邦身份管理。在 SaaS 领域用联邦身份管理会给安全性带来很多新的挑战。(典型的用例会涉及到跨域、基于 Web 的单点登录、跨域用户帐户供应、跨域授权管理和跨域用户属性交换等。详细信息请参见 参考资料,里面的文章链接 “Meeting the SaaS Security Challenge” 有详细的解释)。


Spring Security 简介

Spring Security:比 Acegi 的功能更强大

Spring Security 从 2003 年开始作为 Spring 的 Acegi Security System,直到 2007 年年末它才成为一个官方的 Spring 项目,并重命名为 Spring Security。我们推荐使用 Spring Security(而不是 Acegi)有以下几点原因:

  • 它具备 Acegi 的所有优点,并且有额外的优点。
  • 它利用自定义 Spring 2.0 配置名称空间。
  • 复杂的安全性细节现在隐藏在更简单的 XML 配置之后。
  • 它具有自动配置的能力。

在默认情况下,Java Enterprise Edition(Java EE)5 安全机制不支持承租者 ID(tenant ID)等自定义属性,不管它们是什么样的验证者类型(basic,form,digest 或 client certificate)。要支持多承租就必须要实现自定义的解决方案。在本文中,我们将展示如何使用 Spring Security 来构建这样的解决方案。

Spring Security 提供了一个综合的安全解决方案,这个方案大大地简化了在 Java EE 应用程序中开发安全措施的工作。它提供了更高级的摘要,能够让您插入不同的身份验证模型,同时还支持丰富的身份验证功能。此外,它还在不同的应用程序服务器之间提供了高度可移植性。Spring Security 有以下特性:

  • 声明性安全性
  • 支持各种身份验证和授权机制,如 basic、form、digest、JDBC 和 LDAP
  • 支持方法级别的安全性以及 JSR-250 安全性注释
  • 支持单点登录
  • 支持容器集成
  • 支持匿名对话、并行对话、remember-me、通道加强等

本文的重点是直接集成 Spring Security 和 LDAP。其他的部署场景可能会考虑到其他的方法,如 Java 身份验证和授权服务(Java Authentication and Authorization Service,JAAS),或者是由 Spring Security 框架提供的容器适配器进行的容器管理身份验证。


LDAP 与 Apache Directory 概述

在企业中,管理用户和角色的常见方法是使用 LDAP 服务器。有几个开源的商业 LDAP 解决方案可供选择(参见 参考资料)。考虑到有些读者可能不熟悉 LDAP 或 Apache Directory Server,接下来我们对其进行概述。

LDAP 的核心

LDAP 本质上就是一个数据库。但它趋向于包含更多描述性的、基于属性的信息。由于 LDAP 目录中的信息的读多于写,所以 LDAP 被设计为读最优化。最常见的例子就是电话簿,它里面的每一人都附有地址和电话号码。

作为身份验证和授权源,LDAP 与关系数据库管理系统性相比有以下优点:

  • 有线协议;无需驱动器
  • 灵活的模式
  • 以身份为中心
    • 身份验证、授权和审计
    • 安全性(密码、证书)

Apache Directory Server 的核心

Apache Directory Server 是一个可嵌入的、可扩展的、遵从标准的开源 LDAP 服务器,它由 Java 语言编写而成。我们为本文的解决方案选择 Apache Directory Server 的理由是它的简单性,因为它是一个纯 Java 实现。对于现实中的应用程序,您一定要正确衡量哪一个 LDAP 解决方案最符合您的业务和技术需求。

Apache Directory 项目提供了一个 Apache Directory Server,它遵从 LDAP v3 和 Apache Directory Studio,后者是一组基于 Eclipse 的目录工具(参见 参考资料)。


在多承租的环境中集成 Spring Security 和 Apache Directory Server

在一般情况下,配置 Spring Security 使其能够协同 LDAP 服务器进行工作很简单。虽然在多承租的环境中集成它们也相对容易,但还是比一般情况复杂些。我们首先论述如何在 Apache Directory Server 中创建一个多承租用户注册表,然后再展示一个动态 LDAP 路由解决方案如何为一个有效的多承租安全性解决方案提供便利。

多承租 Apache Directory Server 用户注册表

本小节描述一个示例多承租用户注册表,它由两个安全性区域组成,名为 tenant1 和 tenant2,每一区域个都有两组不同的用户:管理员和访问者。每一组都链接着许多用户,这些用户共用一个特定角色,而且都属于该组的相应安全区域。

不同区域的用户凭证储存于一个 Apache Directory Server 用户注册表中,位于不同的子树下,如 图 1所示。将不同的 LDAP 后缀分配给不同的安全区域。例如,tenant1 的基本专有名称(Base Distinguished Name,DN)是 [dc=tenant1, dc=com],tenant2 的基本 DN 为 [dc=tenant2, dc=com]。

图 1. 多承租 LDAP 用户注册表示例
多承租 LDAP 用户注册表示例

然后,不同的用户组(在现实中转换成了用户角色)会被分配到对应的每一个安全区域。例如,组 [cn=adm, ou=groups] 和组 [cn=gst, ou=groups](分别转换成管理员和访问者)属于基本 DN 为 [dc=tenant1, dc=com] 的 tenant1 安全区域。

反过来,不同的用户条目与一个特定安全区域下的特定组相关联。例如,用户 [uid=tenant1admin, ou=people] 被赋予管理员的角色 [cn=adm, ou=groups] ,属于安全区域 [dc=tenant1, dc=com]。

一定要在 Apache Directory Server 中的 server.xml 配置文件(Apache Directory Server Install Directory/instances/default/conf/server.xml)中为 图 1中的每一个安全区域创建一个新的分区和安全上下文条目,如 清单 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 添加一个新的分区和安全性上下文条目。示例 Web 应用程序中有一个简单的示例 .server.xml 文件。

创建了安全区域后,就可以使用 LDIF Import Wizard —— Apache Directory Studio Eclipse 插件中的特色工具(参见 参考资料)—— 将类似于 清单 2中的 LDAP Date Interchange Format(LDIF)文件的内容导入到其相应的安全性区域中。示例 Web 应用程序提供示例 LDIF 文件。

清单 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)。在一个多承租环境中,这针对一个 LDAP 源(根据承租者的 ID 动态生成)转换成身份验证和授权。

图 2. 多承租动态 LDAP 路由
多承租动态 LDAP 路由

Spring 本身不提供动态 LDAP 路由,所以需要亲自构建。我们的想法受到类似解决方案 —Spring 的 AbstractRoutingDataSource—(参见 参考资料)的启发。

我们通过封装三个主要的类来实现动态 LDAP 路由:

  • 清单 3中所示的 AbstractRoutingSpringSecurityContextSource是一个抽象的实现,它基于 Spring 的 LdapContextSource类。它引用一组 “真实的” 安全性上下文源(参见 targetSpringSecurityContextSources),它的目的是根据固定的查找密钥将调用路由到众多的目标安全性上下文源之一(参见 getResolvedContextSource())。
    清单 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();
      }
    
     }
  • 第二个类是 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;
      }
    
     }
  • 最后一个类是 TenantSecurityContextHolder class,如 清单 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 应用程序中启用它。只要设置了引用承租者 ID 的绑定线程上下文,就可以通过很多种方式来实现这个目的。其中的一个方法就是使用 servlet 过滤器,它负责完成这项任务。

清单 6所示的安全性 servlet 过滤器正常工作的前提是:用户登录时,承租者 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 的主要强项之一就是它的 逆向控制(Inversion of Control)(IoC)原则实现,后者清楚地将应用程序的配置和依赖项规范与实际的应用程序代码区分了开来(参见 参考资料)。但人们还是经常会抱怨 Spring 的配置文件 —— 通常都是 XML 格式的 —— 有可能会变得冗长笨重。幸运的是,自从在 Spring 2.0 中引入了名称空间配置特性之后,Spring Security XML 配置就大大减少了。

通过使用 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.pringframework.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>

验证多承租身份验证

清单 9展示了一个 JUnit 测试,它得益于 Spring 对集成测试的支持。该集成测试允许我们验证多承租身份验证过程是否按预期运行,并且不需要将代码部署到一个应用服务器。但是,该测试一定要运行 Apache Directory Server,充当身份验证供应商。本测试的主要目的是尝试根据两个安全区域(tenant1 和 tenant2)来验证各种用户,其中的用户有些是有效的,有些是无效的。下面的代码清单显示了详细的步骤。

清单 9. 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. onSetUp()方法中设置一组模拟对象和 TenantSecurityContextFilter
  2. 每次运行测试都会调用的 testMultiTenantAuthenticationWorksAsExpected()方法通过分别调用 assertGivenUserCredentialsAreValid()assertGivenUserCredentialsAreInvalid()确保有效用户获得访问权,而无效用户不能进行访问。
  3. assertGivenUserCredentialsAreValid()方法被调用时,它会尝试验证给定用户,确定用户已按预期进行了身份验证。
  4. assertGivenUserCredentialsAreInvalid()方法被调用时,它会尝试验证给定用户(其凭证无效),确定拒绝用户的访问,然后返回到登录页面。
  5. 无论是被 assertGivenUserCredentialsAreValid()调用,还是被 assertGivenUserCredentialsAreInvalid()调用,performUserAuthentication()方法都会触发实际的身份验证过程:
    1. 以给定的承租者 ID、用户名和密码作为请求参数,向我们先前创建的 TenantSecurityContextFilter实例发出一个伪 HTTP 登陆请求。
    2. 我们的安全性过滤器解释该登陆请求,提取承租者 ID,并在将请求传递给 passThroughFilterChain前将其作为一个绑定线程的上下文储存在 TenantSecurityContextHolder中。您可能已经注意,在 onSetUp()中,passThroughFilterChain被设置为标准 Spring Security 表格登陆过滤器的包装器,该过滤器是在后台由 LDAP 身份验证供应商创建的。这个身份验证供应商在 application-context-security.xml 中定义,并被配置成使用 TenantRoutingSpringSecurityContextSource作为 LDAP 上下文源。因此,当 Spring Security 尝试获取上下文源进行验证时,TenantRoutingSpringSecurityContextSource(它扩展了 AbstractRoutingSpringSecurityContextSource)会根据保存在 TenantSecurityContextHolder中的承租者 ID 动态地方返回正确的 LDAP 源。

显示一个闪亮的绿色成功条并不能表明什么,因此,清单 10以 JUnit 测试的形式展示了在 Eclipse 内部运行 MultiTenantAuthenticationTest而生成的控制台输出:

清单 10. 测试输出
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 是一个框架,它的目的是将 Java 开发人员从基于 Spring 的 Java 应用程序的常见基础设施细节中解放出来(参见 参考资料)。

虽然示例 Web 应用程序是在考虑安全性的情况下设计的,但您仍然要意识到它存在很多潜在的漏洞 —包括跨站点脚本、伪请求和对话拦截。在现实中保护企业 Web 应用程序时一定要考虑到这些方面。Open Web Application Security Project(OWASP)(参见 参考资料)保存了很多针对这些类型的安全风险的有用参考资料。

示例 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 的帮助下,这个应用程序成功地部署到以下平台并进行了测试:

  • Apache Tomcat 6,运行在 Java SE 6
  • 嵌入了 Tomcat 6 或 Jetty 6 的 Apache Geronimo 2.1,运行于 Java SE 5

现在就 下载示例应用程序,并开始探索它。下载内容包含有一个 readme.html 文件,它会逐步引导您构建并运行这个应用程序。


结束语

本文分析了实现多承租应用程序的安全性必须考虑重要问题。我们展示了如何设置多承租 Apache Directory Server 用户注册表,以及如何利用 Spring Security 框架根据 LDAP 多承租源进行身份验证和授权。您还学会了如何通过动态 LDAP 路由解决方案的帮助,在一个多承租的生态系统中集成 Spring Security 和 Apache Directory Server。

虽然我们提倡本文提出的解决方案,但仍然建议您仔细斟酌,正确选择最符合您的 SaaS 需求的技术解决方案。您可以查看本文的 参考资料,找到构建 SaaS 解决方案的其他方法。

致谢

David Jencks 和 Paul Browne 在审校本文时提出了很多宝贵建议,在此向他们表示衷心的感谢。此外,还有感谢 Kevan Miller 和 Geronimo and WebSphere Community Edition 团队提供的帮助。


下载

描述名字大小
SaaS Security PoC - 示例应用程序j-saas.zip47KB

参考资料

学习

获得产品和技术

  • Apache Directory Server:下载 Apache Directory Server。
  • Apache Directory Studio:从 Eclipse 更新站点上可以找到 Apache Directory Studio。
  • Spring LDAP:下载 Spring LDAP,它用于简化 LDAP 操作的 Java 库。
  • 下载 IBM 产品评估版,并开始使用来自 DB2®、Lotus®、Rational®、Tivoli®和 WebSphere®的应用程序开发工具和中间件产品。

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=347726
ArticleTitle=保护多承租 SaaS 应用程序
publish-date=10242008