SaaS 能夠提供宿主的軟件應(yīng)用程序,并且可能向未開發(fā)市場領(lǐng)域提供服務(wù),從而使服務(wù)供應(yīng)商實現(xiàn)規(guī)模經(jīng)濟。SaaS 的多承租 的最大優(yōu)點是:它允許服務(wù)供應(yīng)商向多個客戶機組織提供服務(wù)(參見 參考資料)。在 SaaS 應(yīng)用程序中有很多用戶共享相同的資源,所以保護它的最好的方法就是對數(shù)據(jù)和配置(基于承租者 ID)進行邏輯分區(qū),從而確保多承租的安全。
本文展示了如何實現(xiàn)第一道有效防線來保護基于 Java 的多承租 SaaS 應(yīng)用程序。該解決方案結(jié)合使用了 Spring Security(一個經(jīng)久不衰的開源安全框架)和 Apache Directory Server(一個基于 Java 的流行服務(wù)器,它是開源的并且遵從 Lightweight Directory Access Protocol v3,即 LDAP v3)。本文提出的解決方案是一個 示例 Java Web 應(yīng)用程序,它既可以部署到 Apache Tomcat,也可以部署到 Apache Geronimo。
本文重點介紹 SaaS 模型內(nèi)的身份驗證和授權(quán)機制。其他有關(guān) SaaS 安全性的概念和技術(shù) — 比如數(shù)據(jù)保密與隔離、法規(guī)、審計和密碼等,超出了本文的范圍。
多承租的 SaaS 應(yīng)用程序中的身份驗證與授權(quán)
身份驗證和授權(quán)是現(xiàn)實應(yīng)用程序的安全性概念中主要的兩個:
身份驗證和授權(quán)在 SaaS 應(yīng)用程序中很復(fù)雜。在一個安全性 SaaS 解決方案中,底層的身份驗證和授權(quán)基礎(chǔ)設(shè)施有兩種設(shè)計方法:集中式或聯(lián)邦式。本文提出的解決方案使用集中式身份驗證系統(tǒng)(LDAP 服務(wù)器)。集中式的身份驗證系統(tǒng)并不排除支持分布式目錄的可能性,分布式目錄儲存了可以分區(qū)和復(fù)制的信息。本文不考慮采用另一種分散式處理方法,即聯(lián)邦身份管理。在 SaaS 領(lǐng)域用聯(lián)邦身份管理會給安全性帶來很多新的挑戰(zhàn)。(典型的用例會涉及到跨域、基于 Web 的單點登錄、跨域用戶帳戶供應(yīng)、跨域授權(quán)管理和跨域用戶屬性交換等。詳細信息請參見 參考資料,里面的文章鏈接 “Meeting the SaaS Security Challenge” 有詳細的解釋)。
![]() ![]() |
![]() |
|
在默認情況下,Java Enterprise Edition(Java EE)5 安全機制不支持承租者 ID(tenant ID)等自定義屬性,不管它們是什么樣的驗證者類型(basic,form,digest 或 client certificate)。要支持多承租就必須要實現(xiàn)自定義的解決方案。在本文中,我們將展示如何使用 Spring Security 來構(gòu)建這樣的解決方案。
Spring Security 提供了一個綜合的安全解決方案,這個方案大大地簡化了在 Java EE 應(yīng)用程序中開發(fā)安全措施的工作。它提供了更高級的摘要,能夠讓您插入不同的身份驗證模型,同時還支持豐富的身份驗證功能。此外,它還在不同的應(yīng)用程序服務(wù)器之間提供了高度可移植性。Spring Security 有以下特性:
本文的重點是直接集成 Spring Security 和 LDAP。其他的部署場景可能會考慮到其他的方法,如 Java 身份驗證和授權(quán)服務(wù)(Java Authentication and Authorization Service,JAAS),或者是由 Spring Security 框架提供的容器適配器進行的容器管理身份驗證。
![]() ![]() |
在企業(yè)中,管理用戶和角色的常見方法是使用 LDAP 服務(wù)器。有幾個開源的商業(yè) LDAP 解決方案可供選擇(參見 參考資料)。考慮到有些讀者可能不熟悉 LDAP 或 Apache Directory Server,接下來我們對其進行概述。
LDAP 本質(zhì)上就是一個數(shù)據(jù)庫。但它趨向于包含更多描述性的、基于屬性的信息。由于 LDAP 目錄中的信息的讀多于寫,所以 LDAP 被設(shè)計為讀最優(yōu)化。最常見的例子就是電話簿,它里面的每一人都附有地址和電話號碼。
作為身份驗證和授權(quán)源,LDAP 與關(guān)系數(shù)據(jù)庫管理系統(tǒng)性相比有以下優(yōu)點:
Apache Directory Server 是一個可嵌入的、可擴展的、遵從標準的開源 LDAP 服務(wù)器,它由 Java 語言編寫而成。我們?yōu)楸疚牡慕鉀Q方案選擇 Apache Directory Server 的理由是它的簡單性,因為它是一個純 Java 實現(xiàn)。對于現(xiàn)實中的應(yīng)用程序,您一定要正確衡量哪一個 LDAP 解決方案最符合您的業(yè)務(wù)和技術(shù)需求。
Apache Directory 項目提供了一個 Apache Directory Server,它遵從 LDAP v3 和 Apache Directory Studio,后者是一組基于 Eclipse 的目錄工具(參見 參考資料)。
![]() ![]() |
在多承租的環(huán)境中集成 Spring Security 和 Apache Directory Server
在一般情況下,配置 Spring Security 使其能夠協(xié)同 LDAP 服務(wù)器進行工作很簡單。雖然在多承租的環(huán)境中集成它們也相對容易,但還是比一般情況復(fù)雜些。我們首先論述如何在 Apache Directory Server 中創(chuàng)建一個多承租用戶注冊表,然后再展示一個動態(tài) LDAP 路由解決方案如何為一個有效的多承租安全性解決方案提供便利。
多承租 Apache Directory Server 用戶注冊表
本小節(jié)描述一個示例多承租用戶注冊表,它由兩個安全性區(qū)域組成,名為 tenant1 和 tenant2,每一區(qū)域個都有兩組不同的用戶:管理員和訪問者。每一組都鏈接著許多用戶,這些用戶共用一個特定角色,而且都屬于該組的相應(yīng)安全區(qū)域。
不同區(qū)域的用戶憑證儲存于一個 Apache Directory Server 用戶注冊表中,位于不同的子樹下,如 圖 1 所示。將不同的 LDAP 后綴分配給不同的安全區(qū)域。例如,tenant1 的基本專有名稱(Base Distinguished Name,DN)是 [dc=tenant1, dc=com],tenant2 的基本 DN 為 [dc=tenant2, dc=com]。
然后,不同的用戶組(在現(xiàn)實中轉(zhuǎn)換成了用戶角色)會被分配到對應(yīng)的每一個安全區(qū)域。例如,組 [cn=adm, ou=groups] 和組 [cn=gst, ou=groups](分別轉(zhuǎn)換成管理員和訪問者)屬于基本 DN 為 [dc=tenant1, dc=com] 的 tenant1 安全區(qū)域。
反過來,不同的用戶條目與一個特定安全區(qū)域下的特定組相關(guān)聯(lián)。例如,用戶 [uid=tenant1admin, ou=people] 被賦予管理員的角色 [cn=adm, ou=groups] ,屬于安全區(qū)域 [dc=tenant1, dc=com]。
一定要在 Apache Directory Server 中的 server.xml 配置文件(Apache Directory Server Install Directory/instances/default/conf/server.xml)中為 圖 1 中的每一個安全區(qū)域創(chuàng)建一個新的分區(qū)和安全上下文條目,如 清單 1 所示:
<?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 添加一個新的分區(qū)和安全性上下文條目。示例 Web 應(yīng)用程序 中有一個簡單的示例 .server.xml 文件。
創(chuàng)建了安全區(qū)域后,就可以使用 LDIF Import Wizard —— Apache Directory Studio Eclipse 插件中的特色工具(參見 參考資料)—— 將類似于 清單 2 中的 LDAP Date Interchange Format(LDIF)文件的內(nèi)容導(dǎo)入到其相應(yīng)的安全性區(qū)域中。示例 Web 應(yīng)用程序 提供示例 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 的動態(tài) LDAP 路由
動態(tài) LDAP 路由的原理是在運行時根據(jù)查找密鑰動態(tài)地選擇 LDAP 安全性上下文的可能性(參見 圖 2)。在一個多承租環(huán)境中,這針對一個 LDAP 源(根據(jù)承租者的 ID 動態(tài)生成)轉(zhuǎn)換成身份驗證和授權(quán)。
Spring 本身不提供動態(tài) LDAP 路由,所以需要親自構(gòu)建。我們的想法受到類似解決方案 — Spring 的 AbstractRoutingDataSource
—(參見 參考資料)的啟發(fā)。
我們通過封裝三個主要的類來實現(xiàn)動態(tài) LDAP 路由:
AbstractRoutingSpringSecurityContextSource
是一個抽象的實現(xiàn),它基于 Spring 的 LdapContextSource
類。它引用一組 “真實的” 安全性上下文源(參見 targetSpringSecurityContextSources
),它的目的是根據(jù)固定的查找密鑰將調(diào)用路由到眾多的目標安全性上下文源之一(參見 getResolvedContextSource()
)。 public abstract class AbstractRoutingSpringSecurityContextSource<T |--10--------20--------30--------40--------50--------60--------70--------80--------9| |-------- XML error: The previous line is longer than the max of 90 characters ---------| 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 所示。注意,它實現(xiàn)了抽象方法 determineCurrentLookupKey()
,從而清楚地劃分了邏輯界限。 public class TenantRoutingSpringSecurityContextSource<T extends Serializable> |--10--------20--------30--------40--------50--------60--------70--------80--------9| |-------- XML error: The previous line is longer than the max of 90 characters ---------| extends AbstractRoutingSpringSecurityContextSource<String> { @Override protected String determineCurrentLookupKey() { String lookupKey = TenantSecurityContextHolder.getTenantID(); return lookupKey; } } |
TenantSecurityContextHolder class
,如 清單 5 所示,它保留了一個綁定線程的上下文,該上下文有對承租者 ID 的引用,因此 TenantRoutingSpringSecurityContextSource
類就能在運行時訪問它。 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(); } } |
我們已經(jīng)為動態(tài) LDAP 路由打好了基礎(chǔ),但仍然還要做一些集成工作,以便在一個 Web 應(yīng)用程序中啟用它。只要設(shè)置了引用承租者 ID 的綁定線程上下文,就可以通過很多種方式來實現(xiàn)這個目的。其中的一個方法就是使用 servlet 過濾器,它負責完成這項任務(wù)。
清單 6 所示的安全性 servlet 過濾器正常工作的前提是:用戶登錄時,承租者 ID 被作為請求參數(shù)傳入,然后儲存在 Web 會話中,以便隨后經(jīng)過身份驗證的請求進入時獲取它。
public class TenantSecurityContextFilter implements Filter { |-------10--------20--------30--------40--------50--------60--------70--------80--------9| |-------- XML error: The previous line is longer than the max of 90 characters ---------| 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)原則實現(xiàn),后者清楚地將應(yīng)用程序的配置和依賴項規(guī)范與實際的應(yīng)用程序代碼區(qū)分了開來(參見 參考資料)。但人們還是經(jīng)常會抱怨 Spring 的配置文件 —— 通常都是 XML 格式的 —— 有可能會變得冗長笨重。幸運的是,自從在 Spring 2.0 中引入了名稱空間配置特性之后,Spring Security XML 配置就大大減少了。
通過使用 Spring Security XML 配置,您能夠定義 Spring 應(yīng)用程序上下文文件中的大部分身份驗證和授權(quán)的細節(jié)問題。這樣的細節(jié)問題可能包括 LDAP 身份驗證供應(yīng)商的配置以及基于用戶角色的 URL 級別的授權(quán)。(雖然這里沒有顯示細粒度的授權(quán),但它可能會因為支持方法級安全性而出現(xiàn)在 Spring Security 中)。
雖然 清單 7 所示的應(yīng)用程序安全性上下文 XML 配置文件僅是一個例子,但它清楚地解釋了配置 Spring Security 所需的基本的 XML 組件。但要注意如何自動連入自定義多承租 LDAP 路由安全性上下文源,以提供無縫的多承租集成。
<?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 應(yīng)用程序中啟用 Spring Security,您還要具備應(yīng)用程序安全性上下文和 web.xml 部署描述符文件中的安全性過濾器,如 清單 8 所示:
<?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 對集成測試的支持。該集成測試允許我們驗證多承租身份驗證過程是否按預(yù)期運行,并且不需要將代碼部署到一個應(yīng)用服務(wù)器。但是,該測試一定要運行 Apache Directory Server,充當身份驗證供應(yīng)商。本測試的主要目的是嘗試根據(jù)兩個安全區(qū)域(tenant1 和 tenant2)來驗證各種用戶,其中的用戶有些是有效的,有些是無效的。下面的代碼清單顯示了詳細的步驟。
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()
方法中設(shè)置一組模擬對象和 TenantSecurityContextFilter
。testMultiTenantAuthenticationWorksAsExpected()
方法通過分別調(diào)用 assertGivenUserCredentialsAreValid()
和 assertGivenUserCredentialsAreInvalid()
確保有效用戶獲得訪問權(quán),而無效用戶不能進行訪問。assertGivenUserCredentialsAreValid()
方法被調(diào)用時,它會嘗試驗證給定用戶,確定用戶已按預(yù)期進行了身份驗證。assertGivenUserCredentialsAreInvalid()
方法被調(diào)用時,它會嘗試驗證給定用戶(其憑證無效),確定拒絕用戶的訪問,然后返回到登錄頁面。assertGivenUserCredentialsAreValid()
調(diào)用,還是被 assertGivenUserCredentialsAreInvalid()
調(diào)用,performUserAuthentication()
方法都會觸發(fā)實際的身份驗證過程: TenantSecurityContextFilter
實例發(fā)出一個偽 HTTP 登陸請求。passThroughFilterChain
前將其作為一個綁定線程的上下文儲存在 TenantSecurityContextHolder
中。您可能已經(jīng)注意,在 onSetUp()
中,passThroughFilterChain
被設(shè)置為標準 Spring Security 表格登陸過濾器的包裝器,該過濾器是在后臺由 LDAP 身份驗證供應(yīng)商創(chuàng)建的。這個身份驗證供應(yīng)商在 application-context-security.xml 中定義,并被配置成使用 TenantRoutingSpringSecurityContextSource
作為 LDAP 上下文源。因此,當 Spring Security 嘗試獲取上下文源進行驗證時,TenantRoutingSpringSecurityContextSource
(它擴展了 AbstractRoutingSpringSecurityContextSource
)會根據(jù)保存在 TenantSecurityContextHolder
中的承租者 ID 動態(tài)地方返回正確的 LDAP 源。 顯示一個閃亮的綠色成功條并不能表明什么,因此,清單 10 以 JUnit 測試的形式展示了在 Eclipse 內(nèi)部運行 MultiTenantAuthenticationTest
而生成的控制臺輸出:
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 應(yīng)用程序,它示演示了您至今學(xué)到的所有概念(參見 下載)。它還額外提供了一些用戶和密碼管理功能,它們是用 Spring LDAP 來實現(xiàn)的。Spring LDAP 是一個框架,它的目的是將 Java 開發(fā)人員從基于 Spring 的 Java 應(yīng)用程序的常見基礎(chǔ)設(shè)施細節(jié)中解放出來(參見 參考資料)。
雖然示例 Web 應(yīng)用程序是在考慮安全性的情況下設(shè)計的,但您仍然要意識到它存在很多潛在的漏洞 — 包括跨站點腳本、偽請求和對話攔截。在現(xiàn)實中保護企業(yè) Web 應(yīng)用程序時一定要考慮到這些方面。Open Web Application Security Project(OWASP)(參見 參考資料)保存了很多針對這些類型的安全風險的有用參考資料。
示例 Web 應(yīng)用程序基于 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 的幫助下,這個應(yīng)用程序成功地部署到以下平臺并進行了測試:
現(xiàn)在就 下載 示例應(yīng)用程序,并開始探索它。下載內(nèi)容包含有一個 readme.html 文件,它會逐步引導(dǎo)您構(gòu)建并運行這個應(yīng)用程序。您還可以查看這個應(yīng)用程序在運行時的 Flash 演示。
![]() ![]() |
本文分析了實現(xiàn)多承租應(yīng)用程序的安全性必須考慮重要問題。我們展示了如何設(shè)置多承租 Apache Directory Server 用戶注冊表,以及如何利用 Spring Security 框架根據(jù) LDAP 多承租源進行身份驗證和授權(quán)。您還學(xué)會了如何通過動態(tài) LDAP 路由解決方案的幫助,在一個多承租的生態(tài)系統(tǒng)中集成 Spring Security 和 Apache Directory Server。
雖然我們提倡本文提出的解決方案,但仍然建議您仔細斟酌,正確選擇最符合您的 SaaS 需求的技術(shù)解決方案。您可以查看本文的 參考資料,找到構(gòu)建 SaaS 解決方案的其他方法。
David Jencks 和 Paul Browne 在審校本文時提出了很多寶貴建議,在此向他們表示衷心的感謝。此外,還有感謝 Kevan Miller 和 Geronimo and WebSphere Community Edition 團隊提供的幫助。
![]() ![]() |
描述 | 名字 | 大小 | 下載方法 |
---|---|---|---|
SaaS Security PoC - 示例應(yīng)用程序 | j-saas.zip | 47KB | HTTP |
![]() | ||||
![]() | 關(guān)于下載方法的信息 | ![]() |
AbstractRoutingDataSource
類:針對數(shù)據(jù)源的 Spring 動態(tài)路由解決方案啟發(fā)了本文的動態(tài) LDAP 路由解決方案;另請參見 “Dynamic DataSource Routing”(Mark Fisher,SpringSource Team Blog,2007 年 1 月)。 ![]() | ||
![]() | ![]() | Massimiliano (Max) Parlione 是位于愛爾蘭的 IBM 都柏林軟件實驗室的一名解決方案架構(gòu)師,他致力于微觀金融領(lǐng)域的項目。Massimiliano 于 1995 年 7 月獲得 University of L'Aquila 的計算機科學(xué)專業(yè)(本科)榮譽學(xué)士學(xué)位,并于 2000 年 4 月獲得 University La Sapienza of Rome 的計算機工程專業(yè)博士學(xué)位。他是 IBM 紅皮書 “Introducing IBM Tivoli Monitoring for Web Infrastructure” 和 “IBM Tivoli Monitoring Version 5.1.1 Creating Resource Models and Providers” 的合著者。 |
![]() | ||
![]() | ![]() | Chico Charlesworth 是高級 Java 軟件開發(fā)人員,有超過八年的開發(fā)經(jīng)驗。他于 2000 年獲得英國 Staffordshire University 的計算機科學(xué)專業(yè)榮譽學(xué)士學(xué)位。畢業(yè)后,他一直致力于研究企業(yè) Java 技術(shù),專攻遠程通訊、電子記賬、綠色技術(shù)和微觀金融等行業(yè)。他最感興趣的是 Java EE、開源和軟件 |