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 解决方案中,底层的身份验证和授权基础设施有两种设计方法:集中式或联邦式。本文提出的解决方案使用集中式身份验证系统(LDAP 服务器)。集中式的身份验证系统并不排除支持分布式目录的可能性,分布式目录储存了可以分区和复制的信息。本文不考虑采用另一种分散式处理方法,即 联邦身份管理。在 SaaS 领域用联邦身份管理会给安全性带来很多新的挑战。(典型的用例会涉及到跨域、基于 Web 的单点登录、跨域用户帐户供应、跨域授权管理和跨域用户属性交换等。详细信息请参见 参考资料,里面的文章链接 “Meeting the SaaS Security Challenge” 有详细的解释)。
在默认情况下,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 服务器。有几个开源的商业 LDAP 解决方案可供选择(参见 参考资料)。考虑到有些读者可能不熟悉 LDAP 或 Apache Directory Server,接下来我们对其进行概述。
LDAP 本质上就是一个数据库。但它趋向于包含更多描述性的、基于属性的信息。由于 LDAP 目录中的信息的读多于写,所以 LDAP 被设计为读最优化。最常见的例子就是电话簿,它里面的每一人都附有地址和电话号码。
作为身份验证和授权源,LDAP 与关系数据库管理系统性相比有以下优点:
- 有线协议;无需驱动器
- 灵活的模式
- 以身份为中心
- 身份验证、授权和审计
- 组
- 安全性(密码、证书)
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 用户注册表示例
然后,不同的用户组(在现实中转换成了用户角色)会被分配到对应的每一个安全区域。例如,组 [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 |
动态 LDAP 路由的原理是在运行时根据查找密钥动态地选择 LDAP 安全性上下文的可能性(参见 图 2)。在一个多承租环境中,这针对一个 LDAP 源(根据承租者的 ID 动态生成)转换成身份验证和授权。
图 2. 多承租动态 LDAP 路由
Spring 本身不提供动态 LDAP 路由,所以需要亲自构建。我们的想法受到类似解决方案 —Spring 的 AbstractRoutingDataSource
—(参见 参考资料)的启发。
我们通过封装三个主要的类来实现动态 LDAP 路由:
-
清单 3中所示的
AbstractRoutingSpringSecurityContextSource是一个抽象的实现,它基于 Spring 的LdapContextSource类。它引用一组 “真实的” 安全性上下文源(参见targetSpringSecurityContextSources),它的目的是根据固定的查找密钥将调用路由到众多的目标安全性上下文源之一(参见getResolvedContextSource())。
清单 3. AbstractRoutingSpringSecurityContextSource.javapublic 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.javapublic 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.javapublic 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 应用程序中启用它。只要设置了引用承租者 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 的主要强项之一就是它的 逆向控制(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;
}
} |
该测试模拟了以下这个逻辑流程:
- 在
onSetUp()方法中设置一组模拟对象和TenantSecurityContextFilter。
- 每次运行测试都会调用的
testMultiTenantAuthenticationWorksAsExpected()方法通过分别调用assertGivenUserCredentialsAreValid()和assertGivenUserCredentialsAreInvalid()确保有效用户获得访问权,而无效用户不能进行访问。
-
assertGivenUserCredentialsAreValid()方法被调用时,它会尝试验证给定用户,确定用户已按预期进行了身份验证。
-
assertGivenUserCredentialsAreInvalid()方法被调用时,它会尝试验证给定用户(其凭证无效),确定拒绝用户的访问,然后返回到登录页面。
- 无论是被
assertGivenUserCredentialsAreValid()调用,还是被assertGivenUserCredentialsAreInvalid()调用,performUserAuthentication()方法都会触发实际的身份验证过程:- 以给定的承租者 ID、用户名和密码作为请求参数,向我们先前创建的
TenantSecurityContextFilter实例发出一个伪 HTTP 登陆请求。
- 我们的安全性过滤器解释该登陆请求,提取承租者 ID,并在将请求传递给
passThroughFilterChain前将其作为一个绑定线程的上下文储存在TenantSecurityContextHolder中。您可能已经注意,在onSetUp()中,passThroughFilterChain被设置为标准 Spring Security 表格登陆过滤器的包装器,该过滤器是在后台由 LDAP 身份验证供应商创建的。这个身份验证供应商在 application-context-security.xml 中定义,并被配置成使用TenantRoutingSpringSecurityContextSource作为 LDAP 上下文源。因此,当 Spring Security 尝试获取上下文源进行验证时,TenantRoutingSpringSecurityContextSource(它扩展了AbstractRoutingSpringSecurityContextSource)会根据保存在TenantSecurityContextHolder中的承租者 ID 动态地方返回正确的 LDAP 源。
- 以给定的承租者 ID、用户名和密码作为请求参数,向我们先前创建的
显示一个闪亮的绿色成功条并不能表明什么,因此,清单 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.zip | 47KB | HTTP |
学习
- 您可以参阅本文在 developerWorks 全球网站上的 英文原文。
-
Event: Accelerate Business Value (October 15-16, Palisades, NY):加入 IBM 管理团队,学习如何在您的企业中使用 Saas。
-
Software as a Service (SaaS)和 multitenancy:阅读 Wikipedia 上关于这个话题的文章。
-
“SaaS bright spot in waning economy”(Michael Ybarra,SearchCIO-Midmarket,2008 年 7 月):了解 SaaS 不断发展的详细信息。
-
Spring Security:访问 Spring Security 项目站点。
-
Apache Directory Project:Apache Directory Project 提供遵从 LDAP v3 的 Apache Directory Server和 Apache Directory Studio。
-
OpenLDAP、Fedora Directory Server和 Tivoli®Directory Server:其他一些开源的商业 LDAP 解决方案。
- “Meeting the SaaS Security Challenge”(Andrew K. Burger,E-Commerce News,2007 年 2 月):了解 SaaS 面临的安全性挑战,包括与联邦身份相关的挑战。
-
LDAP Data Interchange Format:Wikipedia 关于 LDIF 的阐述。
-
LDIF Import Wizard:这个用于导入 LDIF 文件的向导包含在 Apache Directory Studio Eclipse 插件中。
-
Spring 的
AbstractRoutingDataSource类:针对数据源的 Spring 动态路由解决方案启发了本文的动态 LDAP 路由解决方案;另请参见 “Dynamic DataSource Routing”(Mark Fisher,SpringSource Team Blog,2007 年 1 月)。 -
The IOC Container:阅读 IOC 原则的 Spring 实现。
-
Open Web Application Security Project (OWASP):OWASP 是一个开放的社区,它主要关注提高应用软件的安全性。
-
JUnit:更多地了解 JUnit 测试框架。
-
Testing:了解 Spring 支持的集成测试。
-
IBM SaaS Demonstration Series:学习技术、技巧和最佳实践,以将软件作为宿主服务交付。
-
“
Securing a composite business service delivered as a software-as-a-service: Part I, secure multitenancy with WebSphere Portal Server”(Indrajit Poddar et al.,developerWorks,2007 年 9 月):本文在一个银行 SaaS 应用程序中分析了各种安全性场景。
-
“使用 IBM 中间件实现 SaaS 解决方案,第 1 部分:挑战和体系结构模式”(Germ á n Goldszmidt 和
Indrajit Poddar,developerWorks,2008 年 4 月):本文描述了解决 SaaS 应用程序中多承租所面临的挑战的方法。
-
“Software as a Service: Building Web delivered SaaS applications on open-source and entry-level IBM middleware”(Mary Taylor,developerWorks,2007 年 12 月):这个演示展示了一组采用开源特性和入门级 IBM 中间件的架构模式,它可以构建低成本的 SaaS 解决方案。
-
“满足软件即服务 (SaaS) 应用程序的安全需求”(Chetan J. Kothari,developerWorks,2007 年 9 月):探索能够在 SaaS 应用程序中解决用户安全身份验证和授权的机制。
-
“Multi-Tenant Data Architecture”(Frederick Chong、Gianpaolo Carraro 和 Roger Wolter,MSDN,2006 年 6 月):本文是一个关于设计多承租应用程序的系列文章之一,它通过三种独特的方法创建数据架构。
-
“Architecture Strategies for Catching the Long Tail”(Frederick Chong 和 Gianpaolo Carraro,MSDN,2006 年 4 月):本文概述了 SaaS 模型,并在更高层次上描述了 SaaS 应用程序的架构。
-
“LdapTemplate: LDAP Programming in Java Made Simple”(Mattias Arthursson 和 Ulrik Sandberg,java.net,2006 年 4 月):本文介绍了一个框架,它能使用 Java 语言进行更简单的 LDAP 编程。
-
“使用 Acegi 保护 Java 应用程序,第 2 部分 : 使用 LDAP 目录服务器”(Bilal Siddiqui,developerWorks,2007 年 5 月):看看如何结合使用 Acegi 和 LDAP 目录服务器,以实现 Java 应用程序的安全性。
-
“在 Apache 目录服务器中存储 Java 对象,第 1 部分”(Bilal Siddiqui,developerWorks,2006 年 5 月):大致了解 Apache Directory Server 及其核心结构。
-
浏览
技术书店,查看关于上述主题和其他技术主题的图书。
-
developerWorks Java 技术专区:这里有数百篇关于 Java 编程的文章。
获得产品和技术
-
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®的应用程序开发工具和中间件产品。
讨论

Massimiliano (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” 的合著者。
