在《使用Acegi保护您的应用(之一)》的文章中,我们讲解了如何将Acegi集成到基于Spring的Web应用中,并介绍了基于Acegi身份认证管理的基本处理。在这篇文章里,我们将继续Acegi身份认证的高级部分。此外,我们还将讲解实施高级身份认证的具体做法。
[b]密码加密的问题[/b]
不管是使用InMemoryDaoImpl还是JdbcDaoImpl,它们的工作是根据待认证用户名获取UserDetails对象。在获取UserDetails后,DaoAuthenticationProvider要做的工作是比较Authentication和UserDetails的匹配关系给出认证成功还是失败的认证结果。
但是DaoAuthenticationProvider根据什么规则判断Authentication和UserDetails的匹配关系呢?简单来说,就是判断以下两者的关系:
Authentication#getCredentials() 是否匹配于UserDetails#getPassword()
如果数据库或内存中保存的用户密码是明文的,那么这一比较过程再简单不过了。但是如果用户密码是加密的,也即UserDetails#getPassword()是加密的密码,由于用户提供的密码(Authentication#getCredentials())是用户的直接输入值,它是非加密的,这时如何判断Authentication#getCredentials()的正确性呢?
这就引出了本节所要介绍的两个关键接口:org.acegisecurity.providers.encoding. PasswordEncoder和org.acegisecurity.providers.dao.SaltSource。PasswordEncoder完成两件工作:
1) 对明文的密码(Authentication#getCredentials())进行编码,得到对应的加密值;
2) 对处于非对称状态(一个是加密的,另一个是明文的)两个密码进行比较并给出判断结果。
PasswordEncoder进行密码比较时,需要使用到一个SaltSource,它代表一个“加密盐”(是专业术语,但称之为加密种子更容易理解一些),对用户提供的密码进行加密时采用的加密盐必须和系统中保存的的用户加密密码所采用加密盐相同。
PasswordEncoder定义了两个接口方法:
String encodePassword(String rawPass, Object salt):对原始未加密的密码通过一定的算法进行加密运算,salt为加密时所用的加密盐。一般使用的是MD5或SHA摘要算法,因为摘要算法有两个特性:1)两个相同的字符串摘要值是相同的;2)通过摘要值不能反推出原来的字符串。这两个特性使得以摘要的方式保存密码是安全的——你无法通过密码摘要获取原始密码。而加密盐使密码安全系数进一步提高:对于相同的密码,只要加密盐不相同,计算出的摘要也是不同的。这里的rawPass是指Authentication#getCredentials().toString();
boolean isPasswordValid(String encPass, String rawPass, Object salt):通过算法判断待认证用户所提供的密码是否是有效的。在计算过程中,需要对rawPass使用salt加密盐进行加密运算以得到加密值,再同encPass比较就可以得出判断结果。这里,encPass是指UserDetails#getPassword(),而rawPass是指Authentication#getCredentials().toString().
Acegi在org.acegisecurity.providers.encoding包中提供了几个常见的PasswordEncoder实现类,在表 2中介绍:
表 2 密码编码器
[img]http://gzwfdy.iteye.com/upload/picture/pic/29305/1666de23-7c13-3244-9d54-861efe7a6084.gif[/img]
SaltSource接口仅有一个Object getSalt(UserDetails user)方法,Acegi为其提供了两个实现类,它们分别是:
org.acegisecurity.providers.dao.salt.ReflectionSaltSource:它允许你在UserDetails中提供一个代表加密盐的属性,属性名通过userPropertyToUse指定。ReflectionSaltSource通过反射机制获取UserDetails中的加密盐,所以该实现类允许不同的用户采用不同的加密盐;
org.acegisecurity.providers.dao.salt.SystemWideSaltSource:该实现类不允许不同用户采用各自的加密盐,它采用全局范围统一的加密盐。你可以通过systemWideSalt指定加密盐的值。
假设,我们保存在T_USER表中的password字段采用了MD5加密并且统一采用全局唯一的加密盐。这时,我们就有必要调整代码清单 7中daoAuthenticationProvider的配置以应用密码编码器:
代码清单 8 applicationContext-acegi-plugin.xml
使用密码编码器
…
<bean id="daoAuthenticationProvider"
class="org.acegisecurity.providers.dao.DaoAuthenticationProvider">
<property name="userDetailsService" ref="userDetailsService" />
<property name="passwordEncoder"> ①采用MD5加密的密码编码器
<bean class="org.acegisecurity.providers.encoding.Md5PasswordEncoder" />
</property>
<property name="saltSource"> ②采用全局统一的加密盐
<bean class="org.acegisecurity.providers.dao.salt.SystemWideSaltSource">
<property name="systemWideSalt" value="baobaotao"/>
</bean>
</property>
</bean>
…
成功登录系统的后置处理
一般的业务系统在用户登录成功后,需要在数据库中记录一条相应的用户登录日志。我们可以通过Acegi提供的事件机制来完成这一功能。当用户身份认证成功后,Acegi会产生一个AuthenticationSuccessEvent事件,该事件是org.springframework.context.ApplicationEvent的子类,所以AuthenticationSuccessEvent是一个Spring容器事件。我们可以定义一个监听器响应该事件以记录用户登录日志,请看LoginSuccessListener的代码:
代码清单 9 LoginSuccessListener
package com.baobaotao.service;
import org.acegisecurity.Authentication;
import org.acegisecurity.event.authentication.AuthenticationSuccessEvent;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
public class LoginSuccessListener implements ApplicationListener {
public void onApplicationEvent(ApplicationEvent event) {
if (event instanceof AuthenticationSuccessEvent) {①判断是否是认证成功的事件
AuthenticationSuccessEvent authEvent = (AuthenticationSuccessEvent)event;
Authentication auth = authEvent.getAuthentication();
String userName = auth.getName();
②这里,我们仅通过一条输出信息的语句模拟记录登录日志的操作
System.out.println("模拟记录用户["+userName+"]成功登录日志...");
}
}
}
通过扩展Spring的ApplicationListener接口定义一个监听Acegi AuthenticationSuccessEvent事务的监听器。在实际系统中,你可以使用DAO替换②处的逻辑,执行记录用户登录日志的操作。在编写好LoginSuccessListener后,剩下的工作就是在Spring容器中声明这个监听器,仅需要一行简单的配置就可以了:
<bean class="com.baobaotao.service.LoginSuccessListener"/>
这样,当用户登录并通过了Acegi的用户身份认证后, LoginSuccessListener监听器的onApplicationEvent()方法将接收到AuthenticationSuccessEvent事件。
在多个请求之间共享SecurityContext
我们知道SecurityContext保持着Acegi框架重要的Authentication对象,而SecurityContext保存在SecurityContextHolder中。然而SecurityContextHolder只为SecurityContext对象提供请求线程范围内的生命周期。也就是说,当一个登录认证请求结束后,用户相关的SecurityContext对象将从SecurityContextHolder中清除了。当用户发起下一个请求时,又必须重新进行身价认证,如何使SecurityContext在Session级别中进行共享呢?
Acegi通过HttpSessionContextIntegrationFilter解决这个问题,当一个请求到达时,它尝试从Session中获取用户关联的SecurityContext并将其放入到SecurityContextHolder中,当请求结束时,HttpSessionContextIntegrationFilter又将SecurityContext转存到HttpSession中。这样,Acegi就通过HttpSessionContextIntegrationFilter将SecurityContext对象在请求级的SecurityContextHolder和Session级的HttpSession中摆渡,从而保证SecurityContext可以在多个请求之间共享。
在过滤器链中,HttpSessionContextIntegrationFilter必须位于认证处理过滤器之前,这样认证处理过滤器当发现SecurityContextHolder中已经拥有和用户关联的经过认证的Authentication时,就可以短路掉用户身份认证的步骤:
代码清单 10 applicationContext-acegi-plugin.xml
通过HttpSession转存请求之间的SecurityContext
<bean id="filterChainProxy" class="org.acegisecurity.util.FilterChainProxy">
<property name="filterInvocationDefinitionSource">
<value>
...
/**=httpSessionContextIntegrationFilter,authenticationProcessingFilter ①
</value>
</property>
</bean>
<bean id="httpSessionContextIntegrationFilter" ②通过HttpSession转存SecurityContext的过滤器
class="org.acegisecurity.context.HttpSessionContextIntegrationFilter"/>
...
在①处,我们将httpSessionContextIntegrationFilter放置在authenticationProcessingFilter之前。如果用户还未通过身份认证,httpSessionContextIntegrationFilter在HttpSession中找不到对应的SecurityContext,这时authenticationProcessingFilter将启用正常的认证流程。反之,如果已经通过了身份认证,SecurityContext将直接从HttpSession中获取。
[b]退出系统的后置处理[/b]
SecurityContext保存在HttpSession中,当用户退出系统时必须清除之,否则SecurityContext将一直保存在HttpSession中,需要等到Session过期后才会被清除,这将造成额外的内存消耗。从另外一个方面说,在退出系统时常常需要执行一些相关的操作,如记录用户退出系统的日志、将登录信息保存到Cookie中等。
Acegi为完成以上一系列由退出系统引发的操作,专门提供了一个退出过滤器:org.acegisecurity.ui.logout.LogoutFilter,它允许我们通过配置完成相关的操作:
代码清单 11 applicationContext-acegi-plugin.xml
退出系统后置处理配置
<bean id="filterChainProxy" class="org.acegisecurity.util.FilterChainProxy">
<property name="filterInvocationDefinitionSource">
<value>
…
/**=httpSessionContextIntegrationFilter,authenticationProcessingFilter,logoutFilter ①
</value>
</property>
</bean>
<bean id="logoutFilter" class="org.acegisecurity.ui.logout.LogoutFilter">②退出系统过滤器
<constructor-arg> ②-1退出系统前需要执行的操作
<list>
<bean class="org.acegisecurity.ui.logout.SecurityContextLogoutHandler" />
</list>
</constructor-arg>
<constructor-arg value="/index.jsp" />②-2退出系统后转向的URL
②-3用于响应退出系统请求的URL
<property name="filterProcessesUrl" value="/j_acegi_logout"/>
</bean>
...
在①处,我们在过滤器链中添加一个logoutFilter,它负责处理用户退出系统的操作。退出系统过滤器需要以下三方面的信息:
1) 哪一个URL是退出系统的HTTP请求,这通过filterProcessesUrl属性指定,②-3所示。LogoutFilter默认的退出系统URL即是“/j_acegi_logout”,这里显式进行配置是为了说明可以根据需要进行调整;
2) 退出系统时需要执行哪些处理器,通过LogoutFilter的构造函数指定,如②-1所示。处理器必须实现org.acegisecurity.ui.logout.LogoutHandler接口,Acegi为该接口提供了两个实现类,分别是SecurityContextLogoutHandler和 TokenBasedRememberMeServices,前者将SecurityContext从HttpSession中删除,而后者将Anthentication中的用户名/密码保存到客户端的Cookie中,以便下次用户访问系统时直接通过Cookie中的用户名/密码进行自动登录。我们将在下一节学习到TokenBasedRememberMeServices的知识。
3) 退出系统后转向哪个URL,通过构造函数参数指定,如②-2所示。
配置好退出系统过滤器后,在需要在系统页面中提供一个退出系统的操作链接:
[b]<A href="<c:url value="/j_acegi_logout"/>">[/b]退出系统</A>
注意粗体所示代码代表退出系统所对应的URL,它必须和LogoutFilter的filterProcessesUrl属性一致。这样,当用户点击页面中的“退出系统”链接后,LogoutFilter拦截这个URL请求,并调用SecurityContextLogoutHandler将SecurityContext从HttpSession中清除掉,最后转向/index.jsp页面。
[b]实施Remember-Me认证[/b]
很多网站的用户登录页面都提供了一个类似于“两周内不用再登录”、“记住我的帐号”等功能,其原理是在用户登录成功后使用客户端浏览器的Cookie记录用户登录信息,当下次再访问相同站点时,直接从Cookie中取得用户登录信息并进行自动登录。这即是经典的Remember-Me的功能,该功能在一定程度上降低了用户频繁登录的麻烦。根据系统安全性需求的不同,Remember-Me可能在Cookie中保存用户名/密码或仅保存用户名,前者可以完成自动登录,而后者只是让用户避免输入用户名。
如果在Cookie中记录用户名/密码,虽然可以避免每次访问网站都进行登录的麻烦,但这把双刃剑的反面是黑客可以在一定条件下获取这个免检的通行证。为了在给用户带来便利的同时尽力降低潜在的风险,Cookie保存用户名/密码的方式变得非常关键,以下几点是必须考虑的问题:
1) Cookie是易受攻击的,多用户共享浏览器和跨站点脚本攻击都可能使Cookie失窃;
2)将用户名/密码保存在Cookie中,意味着用户可以在不显式进行登录的情况下,获取正常登录的一切权限;
3)一切可以从Cookie中反推出密码明文的存储方式都是不可接受的;
4)必须将客户端IP信息绑定在Cookie中,这样即使Cookie失窃,也不可能在其它机器使用。
如果说HttpSessionContextIntegrationFilter通过HttpSession使Authentication获得跨请求共享的能力,那么Remember-Me则通过Cookie使Authentication获得跨多个Session的能力。Remember-Me功能可以视为一套解决方案,以下是Remember-Me中最关键的三个问题:
1) 在用户登录时,获取用户名/密码的信息,并将其以一定方式保存到Cookie中;
2) 在Cookie有效时间内,当用户访问站点安全页面时,自动进行登录;
3) 必须提供一个功能,让用户可以手工清除Remember-Me Cookie。
org.acegisecurity.ui.rememberme.RememberMeServices是Remember-Me方案中最关键的一个接口,它定义了以下几个方法:
void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication):登录成功后调用该方法,将用户名/密码保存到Cookie中;
Authentication autoLogin(HttpServletRequest request, HttpServletResponse response):从Cookie中自动获取用户名/密码进行自动登录。
loginSuccess()和loginFail()方法的调用已经编制到Acegi的AbstractProcessingFilter抽象过滤器中,这意味着任何注入了RememberMeServices实例的过滤器都会以适合的方式调用这两个方法。而autoLogin()方法则通过RememberMeProcessingFilter进行调用,当RememberMeProcessingFilter发现SecurityContextHolder中不存在有效的Authentication时,autoLogin()方法就会被执行。
Acegi为RememberMeServices接口提供了两个实现类,它们分别是:
NullRememberMeServices:类似于适配器的实现类,它不做任何有意义的事情,这是AbstractProcessingFilter中默认的实现类;
TokenBasedRememberMeServices:基于凭证(一般指用户名/密码)的Remember-Me实现类,它真实地实现了接口中的方法。
在登录时将用户名/密码记录到Cookie中
我们第一个要做的工作是通过调整AuthenticationProcessingFilter的配置,在处理用户登录页面提交的用户认证信息时,将用户名/密码通过Response记录到客户端的Cookie中:
代码清单 12 applicationContext-acegi-plugin.xml
记录Remember-Me的Cookie信息 程序开发,操作系统
...
<bean id="authenticationProcessingFilter" class="org.acegisecurity.ui.webapp.AuthenticationProcessingFilter">
...
①注入一个RememberMeServices
<property name="rememberMeServices" ref="rememberMeServices"/>
</bean>
<bean id="rememberMeServices" ②RememberMeServices配置
class="org.acegisecurity.ui.rememberme.TokenBasedRememberMeServices">
<property name="tokenValiditySeconds" value="432000"/> ②-1Cookie有效时间,单位为秒
<property name="key" value="baobaotao"/> ②-2 Cookie中的键值
</bean>
...
通过以上的配置,我们在处理用户登录的同时将用户名/密码的信息记录到Cookie中。authenticationProcessingFilter在完成用户身份认证后,如果认证成功,调用rememberMeServices的loginSuccess()方法,该方法将用户名/密码按以下方式进行编码,编码后再写到客户端的Cookie中:
base64(username + ":" + expirationTime + ":" + md5Hex(username + ":" + expirationTime + ":"+password + ":" + key))
base64()表示进行BASE64编码操作,而md5Hex()表示进行MD5摘要并将结果值以HEX(十六进制)进行编码。注意计算式中粗体所示key操作项,Acegi通过key防止整个加密串被恶意篡改。因为key是在服务端中指定的值,黑客无法进行猜测,在服务端通过如②-2所示的key属性指定该值。
Cookie的有效时间通过tokenValiditySeconds指定,默认为两个星期。②-1处我们显式指定为5天(对应的秒数)。
我们知道authenticationProcessingFilter处理对应/j_acegi_security_check的请求,我们应该让用户决定是否使用Remember-Me的功能,这可以通过一个名为“_acegi_security_remember_me”的HTTP参数来决定,当登录请求表单包含该参数时(勾选上对应的复选框),服务端认为需要启用Remember-Me功能,否则不启用Remember-Me功能。所以,我们必须相应地调整登录页面表单,如代码清单 13所示:
代码清单 13 index.jsp:添加是否使用Remember-Me功能的控制参数
<%@ page contentType="text/html;charset=UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
...
<form name="form1" method="post" action="<c:url value="/j_acegi_security_check"/>">
用户名:<input type="text" name="j_username"/><br/>
密 码:<input type="password" name="j_password"/><br/>
①用户可以通过勾选或取消该复选框决定是否启用Remember-Me功能
<input type="checkbox" name="_acegi_security_remember_me">5天内不用再登录
<input type="submit" value="登录"/>
</form>
...
[b]根据Remember-Me进行自动登录[/b]
如果用户在登录时选择了Remember-Me的功能(即勾选“5天内不用再登录”复选框),登录成功后用户名/密码的信息就保存在客户机的Cookie中。下次用户直接访问站点的安全页面时,必须有一个过滤器能够自动调用RememberMeServices#autoLogin()完成自动登录的操作,这便是通过RememberMeProcessingFilter过滤器来完成的:
代码清单 14 applicationContext-acegi-plugin.xml
Remember-Me自动登录
<bean id="filterChainProxy" class="org.acegisecurity.util.FilterChainProxy">
<property name="filterInvocationDefinitionSource">
<value>
…
/**=httpSessionContextIntegrationFilter,authenticationProcessingFilter,logoutFilter,rememberMeProcessingFilter ①处理自动登录的过滤器
</value>
</property>
</bean>
<bean id="rememberMeProcessingFilter" ②自动登录过滤器
class="org.acegisecurity.ui.rememberme.RememberMeProcessingFilter">
<property name="rememberMeServices" ref="rememberMeServices"/>②-1
<property name="authenticationManager" ref="authenticationManager"/>②-2
</bean>
<bean id="rememberMeServices" ③
class="org.acegisecurity.ui.rememberme.TokenBasedRememberMeServices">
<property name="tokenValiditySeconds" value="432000"/>
<property name="key" value="baobaotao"/>
<property name="userDetailsService" ref="userDetailsService" />③-1
</bean>
<bean id="authenticationManager" class="org.acegisecurity.providers.ProviderManager">
<property name="providers">
<list>
<ref local="daoAuthenticationProvider"/>
<bean class="org.acegisecurity.providers.rememberme. ④
RememberMeAuthenticationProvider">
<property name="key" value=" baobaotao"/>
</bean>
</list>
</property>
</bean>
首先,我们在过滤器链中添加一个rememberMeProcessingFilter,它负责对所有HTTP请求进行拦截,当发现SecurityContextHolder中没有包含有效的Authentication对象时,自动调用RememberMeServices#autoLogin()方法从Cookie中获取用户名/密码的编码串进行自动登录。所以rememberMeProcessingFilter首先要注入一个RememberMeServices Bean,如②-1所示。
在 代码清单 12中已经定义了一个被AuthenticationProcessingFilter 使用的RememberMeServices Bean。我们在前面说过, AuthenticationProcessingFilter在注入RememberMeServices Bean后,就会在适合的时候调用RememberMeServices#loginSuccess()方法将用户凭证保存到Cookie中。而RememberMeProcessingFilter则会调用RememberMeServices#autoLogin()方法对保存在Cookie中的用户凭证执行自动认证的操作。前者是将Authentication中的用户名/密码写入到Cookie中,而后者需要据此获得对应的UserDetails,进而再重现出Authentication对象。正如你所想到的一样,RememberMeServices需要通过一个UserDetailsService来完成这项工作,所以我们需要调整RememberMeServices的配置,如③-1所示。
rememberMeProcessingFilter通过rememberMeServices获取对应Cookie中用户的UserDetails后,就必须进行用户身份认证。这项工作依然委托给authenticationManager完成,所以在②-2中,我们给rememberMeProcessingFilter注入了authenticationManager Bean。
authenticationManager如何对基于Cookie的用户凭证进行认证呢?显然,不能采用原来的daoAuthenticationProvider所用的方法,因为Cookie所提供用户凭证和登录表单提供的用户凭证在格式上存在很大的差异。基于Remember-Me的用户名/密码信息是经过特殊编码的字符串,Acegi通过RememberMeAuthenticationProvider负责对基于Cookie的用户凭证信息进行认证。所以你必须将该认证提供者添加到authenticationManager中,如④所示,注意key属性的设置,它和 代码清单 12中的key必须相同。如果保存在数据库中的密码使用了特殊编码,则你必须为RememberMeAuthenticationProvider配置特定的密码编码器,请参考代码清单 8进行配置。
删除Remember-Me的Cookie
站点如何提供了Remember-Me的功能,就必须同时提供能让用户手工删除Cookie的功能,以便用户在某些情况下,能够删除掉Cookie。Acegi提供了一个并不是很理想的实现:在退出系统时通过配置一个LogoutHandler清除Remember-Me的Cookie。
在上一节中,我们知道SecurityContextLogoutHandler是LogoutHandler的实现类,它负责在退出系统时删除HttpSession中的SecurityContext。LogoutHandler接口的另一个实现类是TokenBasedRememberMeServices(如前所述,它同时也实现了RememberMeServices接口)。你可以在LogoutFilter中添加TokenBasedRememberMeServices,以便用户退出系统时连带清除Remember-Me。
由于TokenBasedRememberMeServices已经在前面配置好了,这里仅需要简单地将其加入到LogoutFilter的LogoutHandler列表中即可,如下所示:
代码清单 15 applicationContext-acegi-plugin.xml:清除Remember-Me的Cookie
<bean id="logoutFilter" class="org.acegisecurity.ui.logout.LogoutFilter">
<constructor-arg value="/index.jsp" />
<constructor-arg>
<list>
①添加清除Remember-Me Cookie的LogoutHandler
<ref bean="rememberMeServices"/>
<bean class="org.acegisecurity.ui.logout.SecurityContextLogoutHandler" />
</list>
</constructor-arg>
<property name="filterProcessesUrl" value="/j_acegi_logout" />
</bean>
...
想象一下,这种处理方式是否合理呢?可以说不合理得近乎点荒唐——用户在登录时选择启用Remember-Me功能,就是希望在退出系统后能够在Cookie中保留用户信息,方便后续系统的访问,现在居然在退出系统后就清除掉这个Cookie。也就是说,Remember-Me Cookie仅在用户登录到用户退出系统这段时间内有效,但这段时间我们根本不需要用到这个Cookie! httpSessionContextIntegrationFilter已经很好地通过HttpSession的转存实现了在不同请求之间共享Authentication的功能。
所以Acegi提供的这种设计,笔者认为只是一个使用范例,开发者必须编写自己的实现类以提供更有意义的实现。如在用户退出系统时,允许用户通过选择的方式决定是否删除Remember-Me的Cookie,或者专门提供一个清除Remember-Me Cookie的操作链接。
小结
使用Acegi,你就可以通过配置的方式完成应用程序的身份认证。这包括对密码进行加密的认证,使用Remember-Me,退出系统后清楚Session等在身份认证时常用的各项功能。用户认证是Acegi保护应用系统的第一步,因为只能获取操作用户的身份后,才能获取用户的权限,并根据用户权限进行程序安全控制。