Spring Security 使用自定义控制器来完成登陆验证

较为简单或者体量较小的技术,完全可以参考着demo直接上手,但系统的学习一门技术则不然。以我的认知,一般的文档大致有两种风格:Architecture First和Code First。前者致力于让读者先了解整体的架构,方便我们对自己的认知有一个宏观的把控,而后者以特定的demo配合讲解,可以让读者在解决问题的过程中顺便掌握一门技术。关注过我博客或者公众号的朋友会发现,我之前介绍技术的文章,大多数是Code First,提出一个需求,介绍一个思路,解决一个问题,分析一下源码,大多如此。而学习一个体系的技术,我推荐Architecture First,正如本文标题所言,这篇文章是我Spring Security系列的第一篇,主要是根据Spring Security文档选择性翻译整理而成的一个架构概览,配合自己的一些注释方便大家理解。写作本系列文章时,参考版本为Spring Security 4.2.3.RELEASE。

1 核心组件

这一节主要介绍一些在Spring Security中常见且核心的Java类,它们之间的依赖,构建起了整个框架。想要理解整个架构,最起码得对这些类眼熟。

1.1 SecurityContextHolder

SecurityContextHolder用于存储安全上下文(security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限…这些都被保存在SecurityContextHolder中。SecurityContextHolder默认使用ThreadLocal 策略来存储认证信息。看到ThreadLocal 也就意味着,这是一种与线程绑定的策略。Spring Security在用户登录时自动绑定认证信息到当前线程,在用户退出时,自动清除当前线程的认证信息。但这一切的前提,是你在web场景下使用Spring Security,而如果是Swing界面,Spring也提供了支持,SecurityContextHolder的策略则需要被替换,鉴于我的初衷是基于web来介绍Spring Security,所以这里以及后续,非web的相关的内容都一笔带过。

获取当前用户的信息

因为身份信息是与线程绑定的,所以可以在程序的任何地方使用静态方法获取用户信息。一个典型的获取当前登录用户的姓名的例子如下所示:

1
2
3
4
5
6
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}

getAuthentication()返回了认证信息,再次getPrincipal()返回了身份信息,UserDetails便是Spring对身份信息封装的一个接口。Authentication和UserDetails的介绍在下面的小节具体讲解,本节重要的内容是介绍SecurityContextHolder这个容器。

1.2 Authentication

先看看这个接口的源码长什么样:

1
2
3
4
5
6
7
8
9
package org.springframework.security.core;// <1>
public interface Authentication extends Principal, Serializable { // <1>
    Collection<? extends GrantedAuthority> getAuthorities(); // <2>
    Object getCredentials();// <2>
    Object getDetails();// <2>
    Object getPrincipal();// <2>
    boolean isAuthenticated();// <2>
    void setAuthenticated(boolean var1) throws IllegalArgumentException;
}

<1> Authentication是spring security包中的接口,直接继承自Principal类,而Principal是位于java.security包中的。可以见得,Authentication在spring security中是最高级别的身份/认证的抽象。
<2> 由这个顶级接口,我们可以得到用户拥有的权限信息列表,密码,用户细节信息,用户身份信息,认证信息。
还记得1.1节中,authentication.getPrincipal()返回了一个Object,我们将Principal强转成了Spring Security中最常用的UserDetails,这在Spring Security中非常常见,接口返回Object,使用instanceof判断类型,强转成对应的具体实现类。接口详细解读如下:

  • getAuthorities(),权限信息列表,默认是GrantedAuthority接口的一些实现类,通常是代表权限信息的一系列字符串。
  • getCredentials(),密码信息,用户输入的密码字符串,在认证过后通常会被移除,用于保障安全。
  • getDetails(),细节信息,web应用中的实现接口通常为 WebAuthenticationDetails,它记录了访问者的ip地址和sessionId的值。
  • getPrincipal(),敲黑板!!!最重要的身份信息,大部分情况下返回的是UserDetails接口的实现类,也是框架中的常用接口之一。UserDetails接口将会在下面的小节重点介绍。

Spring Security是如何完成身份认证的?

1 用户名和密码被过滤器获取到,封装成Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。

2 AuthenticationManager 身份管理器负责验证这个Authentication

3 认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。

4 SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。

这是一个抽象的认证流程,而整个过程中,如果不纠结于细节,其实只剩下一个AuthenticationManager 是我们没有接触过的了,这个身份管理器我们在后面的小节介绍。将上述的流程转换成代码,便是如下的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class AuthenticationExample {
private static AuthenticationManager am = new SampleAuthenticationManager();
public static void main(String[] args) throws Exception {
    BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
    while(true) {
    System.out.println("Please enter your username:");
    String name = in.readLine();
    System.out.println("Please enter your password:");
    String password = in.readLine();
    try {
        Authentication request = new UsernamePasswordAuthenticationToken(name, password);
        Authentication result = am.authenticate(request);
        SecurityContextHolder.getContext().setAuthentication(result);
        break;
    } catch(AuthenticationException e) {
        System.out.println("Authentication failed: " + e.getMessage());
    }
    }
    System.out.println("Successfully authenticated. Security context contains: " +
            SecurityContextHolder.getContext().getAuthentication());
}
}
class SampleAuthenticationManager implements AuthenticationManager {
static final List<GrantedAuthority> AUTHORITIES = new ArrayList<GrantedAuthority>();
static {
    AUTHORITIES.add(new SimpleGrantedAuthority("ROLE_USER"));
}
public Authentication authenticate(Authentication auth) throws AuthenticationException {
    if (auth.getName().equals(auth.getCredentials())) {
    return new UsernamePasswordAuthenticationToken(auth.getName(),
        auth.getCredentials(), AUTHORITIES);
    }
    throw new BadCredentialsException("Bad Credentials");
}
}

注意:上述这段代码只是为了让大家了解Spring Security的工作流程而写的,不是什么源码。在实际使用中,整个流程会变得更加的复杂,但是基本思想,和上述代码如出一辙。

1.3 AuthenticationManager

初次接触Spring Security的朋友相信会被AuthenticationManager,ProviderManager ,AuthenticationProvider …这么多相似的Spring认证类搞得晕头转向,但只要稍微梳理一下就可以理解清楚它们的联系和设计者的用意。AuthenticationManager(接口)是认证相关的核心接口,也是发起认证的出发点,因为在实际需求中,我们可能会允许用户使用用户名+密码登录,同时允许用户使用邮箱+密码,手机号码+密码登录,甚至,可能允许用户使用指纹登录(还有这样的操作?没想到吧),所以说AuthenticationManager一般不直接认证,AuthenticationManager接口的常用实现类ProviderManager 内部会维护一个List<AuthenticationProvider>列表,存放多种认证方式,实际上这是委托者模式的应用(Delegate)。也就是说,核心的认证入口始终只有一个:AuthenticationManager,不同的认证方式:用户名+密码(UsernamePasswordAuthenticationToken),邮箱+密码,手机号码+密码登录则对应了三个AuthenticationProvider。这样一来四不四就好理解多了?熟悉shiro的朋友可以把AuthenticationProvider理解成Realm。在默认策略下,只需要通过一个AuthenticationProvider的认证,即可被认为是登录成功。

只保留了关键认证部分的ProviderManager源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class ProviderManager implements AuthenticationManager, MessageSourceAware,
        InitializingBean {
    // 维护一个AuthenticationProvider列表
    private List<AuthenticationProvider> providers = Collections.emptyList();
    public Authentication authenticate(Authentication authentication)
          throws AuthenticationException {
       Class<? extends Authentication> toTest = authentication.getClass();
       AuthenticationException lastException = null;
       Authentication result = null;
       // 依次认证
       for (AuthenticationProvider provider : getProviders()) {
          if (!provider.supports(toTest)) {
             continue;
          }
          try {
             result = provider.authenticate(authentication);
             if (result != null) {
                copyDetails(authentication, result);
                break;
             }
          }
          ...
          catch (AuthenticationException e) {
             lastException = e;
          }
       }
       // 如果有Authentication信息,则直接返回
       if (result != null) {
            if (eraseCredentialsAfterAuthentication
                    && (result instanceof CredentialsContainer)) {
                 //移除密码
                ((CredentialsContainer) result).eraseCredentials();
            }
             //发布登录成功事件
            eventPublisher.publishAuthenticationSuccess(result);
            return result;
       }
       ...
       //执行到此,说明没有认证成功,包装异常信息
       if (lastException == null) {
          lastException = new ProviderNotFoundException(messages.getMessage(
                "ProviderManager.providerNotFound",
                new Object[] { toTest.getName() },
                "No AuthenticationProvider found for {0}"));
       }
       prepareException(lastException, authentication);
       throw lastException;
    }
}

ProviderManager 中的List,会依照次序去认证,认证成功则立即返回,若认证失败则返回null,下一个AuthenticationProvider会继续尝试认证,如果所有认证器都无法认证成功,则ProviderManager 会抛出一个ProviderNotFoundException异常。

到这里,如果不纠结于AuthenticationProvider的实现细节以及安全相关的过滤器,认证相关的核心类其实都已经介绍完毕了:身份信息的存放容器SecurityContextHolder,身份信息的抽象Authentication,身份认证器AuthenticationManager及其认证流程。姑且在这里做一个分隔线。下面来介绍下AuthenticationProvider接口的具体实现。

1.4 DaoAuthenticationProvider

AuthenticationProvider最最最常用的一个实现便是DaoAuthenticationProvider。顾名思义,Dao正是数据访问层的缩写,也暗示了这个身份认证器的实现思路。由于本文是一个Overview,姑且只给出其UML类图:

按照我们最直观的思路,怎么去认证一个用户呢?用户前台提交了用户名和密码,而数据库中保存了用户名和密码,认证便是负责比对同一个用户名,提交的密码和保存的密码是否相同便是了。在Spring Security中。提交的用户名和密码,被封装成了UsernamePasswordAuthenticationToken,而根据用户名加载用户的任务则是交给了UserDetailsService,在DaoAuthenticationProvider中,对应的方法便是retrieveUser,虽然有两个参数,但是retrieveUser只有第一个参数起主要作用,返回一个UserDetails。还需要完成UsernamePasswordAuthenticationToken和UserDetails密码的比对,这便是交给additionalAuthenticationChecks方法完成的,如果这个void方法没有抛异常,则认为比对成功。比对密码的过程,用到了PasswordEncoder和SaltSource,密码加密和盐的概念相信不用我赘述了,它们为保障安全而设计,都是比较基础的概念。

如果你已经被这些概念搞得晕头转向了,不妨这么理解DaoAuthenticationProvider:它获取用户提交的用户名和密码,比对其正确性,如果正确,返回一个数据库中的用户信息(假设用户信息被保存在数据库中)。

1.5 UserDetails与UserDetailsService

上面不断提到了UserDetails这个接口,它代表了最详细的用户信息,这个接口涵盖了一些必要的用户信息字段,具体的实现类对它进行了扩展。

1
2
3
4
5
6
7
8
9
public interface UserDetails extends Serializable {
   Collection<? extends GrantedAuthority> getAuthorities();
   String getPassword();
   String getUsername();
   boolean isAccountNonExpired();
   boolean isAccountNonLocked();
   boolean isCredentialsNonExpired();
   boolean isEnabled();
}

它和Authentication接口很类似,比如它们都拥有username,authorities,区分他们也是本文的重点内容之一。Authentication的getCredentials()与UserDetails中的getPassword()需要被区分对待,前者是用户提交的密码凭证,后者是用户正确的密码,认证器其实就是对这两者的比对。Authentication中的getAuthorities()实际是由UserDetails的getAuthorities()传递而形成的。还记得Authentication接口中的getUserDetails()方法吗?其中的UserDetails用户详细信息便是经过了AuthenticationProvider之后被填充的。

1
2
3
public interface UserDetailsService {
   UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetailsService和AuthenticationProvider两者的职责常常被人们搞混,关于他们的问题在文档的FAQ和issues中屡见不鲜。记住一点即可,敲黑板!!!UserDetailsService只负责从特定的地方(通常是数据库)加载用户信息,仅此而已,记住这一点,可以避免走很多弯路。UserDetailsService常见的实现类有JdbcDaoImpl,InMemoryUserDetailsManager,前者从数据库加载用户,后者从内存中加载用户,也可以自己实现UserDetailsService,通常这更加灵活。

 

第一部分: web.xml的配置
使用过SpringSecurity的朋友都知道,首先需要在web.xml进行以下配置:

<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>

<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping></span>

从这个配置中, 可能会给我们造成一个错觉, 以为DelegatingFilterProxy类就是SpringSecurity的入口, 但其实这个类位于spring-web-3.0.5.RELEASE.jar这个jar下面, 说明这个类本身是和SpringSecurity无关. 这个类的作用其实是把过滤这个动作代理给springSecurityFilterChain所对应的类(即: FilterChainProxy)来处理.
(详细请参考: http://www.cnblogs.com/hzhuxin/archive/2011/12/19/2293730.html)

当然<filter-name>标签里也可以配一个自定义的filter bean, 这个bean要在applicationContext.xml里定义. 具体以后再补充.
第二部分: applicationContext-security.xml的配置
1. 配置一些不需要安全验证的资源:

<sec:http pattern="/login" security="none"></sec:http>
<sec:http pattern="/register" security="none"></sec:http>
<sec:http pattern="/**/*.js" security="none"></sec:http>
2. 配置AuthenticationManager

<sec:authentication-manager alias="authenticationManager">
<sec:authentication-provider ref="authenticationProvider"/>
</sec:authentication-manager>

<bean id="authenticationProvider" class="org.springframework.security.authentication.dao.DaoAuthenticationProvider">
<property name="userDetailsService" ref="userService" />
</bean>

<bean id="userService" class="com.demo.service.impl.UserService"/>

在Spring Security 3.0之前, AuthenticationManager会被自动创建. 但在3.0之后, 我们需要手动配置<authentication-manager>标签. 这个标签会创建一个ProviderManager实例. ProviderManager可以有一个或多个AuthenticationProvider(如: dao,ldap,cas等等). 如果我们把权限信息都存在数据库里, 那这里就需要配置一个DaoAuthenticationProvider实例.

DaoAuthenticationProvider里需要配置一个实现了UserDetailsService接口的实例, 重写loadUserByUsername方法. 这其实就是我们经常要在业务层里写的东西. 例子如下:

@Service
public class UserService implements IUserService, UserDetailsService {

@Autowired
private SqlSessionTemplate template;

@Override
public User loadUserByUsername(String username) throws UsernameNotFoundException {
User user = template.selectOne("getUserByName", username);
Collection<GrantedAuthority> auths = getUserRoles(username);
user.setAuthorities(auths);
return user;
}

@Override
public Collection<GrantedAuthority> getUserRoles(String username) {
List<String> roleList = template.selectList("getUserRoles", username);
Collection<GrantedAuthority> auths = new ArrayList<GrantedAuthority>();
for (String role : roleList) {
auths.add(new SimpleGrantedAuthority(role));
}
return auths;
}
...
}
要注意的的是:
1) User要实现org.springframework.security.core.userdetails.UserDetails接口;

2) User里面要有一个权限集合的属性, 如: private Collection<? extends GrantedAuthority> authorities;

3) loadUserByUsername()方法除了要从数据库里拿出用户的具体信息之外, 还要拿出用户的权限信息, 这样后面的AbstractSecurityInterceptor.beforeInvocation(object)方法才能对用户作权限验证.

3. 配置收到HTTP请求时的安全验证配置:

<sec:http entry-point-ref="myAuthenticationEntryPoint">
<sec:intercept-url pattern="/**" access="ROLE_USER"/>
<sec:access-denied-handler ref="accessDeniedHandler"/>
<sec:custom-filter ref="loginAuthenticationFilter" position="FORM_LOGIN_FILTER"/>
<sec:custom-filter ref="filterSecurityInterceptor" before="FILTER_SECURITY_INTERCEPTOR"/>
</sec:http>

3.1 entry-point-ref
配置一个AuthenticationEntryPoint的实现类. 这个类的作用是, 当一个未授权的用户请求非公有资源时, 这个类的commence方法将会被调用, 定义如何处理这个请求. 常用的有LoginUrlAuthenticationEntryPoint实现类, 把请求重定向到登录页面. 也可以自定义一个实现类, 处理具体的操作, 如记录日志, 返回到自定义的403页面等等.

3.2 <sec:intercept-url pattern="/**" access="ROLE_USER"/>
这个用来配置访问哪些资源需要哪些权限/角色, 不多说明.

3.3 access-denied-handler
配置一个AccessDeniedHandler的实现类. 这个类的作用是, 当一个已授权(或已登陆)的用户请求访问他权限之外的资源时, 这个类的handle方法将会被调用, 定义如何处理这个请求.
注意AccessDeniedHandler与AuthenticationEntryPoint的区别:
AccessDeniedHandler: 已授权的用户请求权限之外的资源时会交给这个类处理.
AuthenticationEntryPoint: 未授权的用户请求非公共资源时会交给这个类处理.

3.4 custom-filter (重点)
配置自定义的过滤器, 一般要自己配置UsernamePasswordAuthenticationFilter和FilterSecurityInterceptor.
Spring Security的安全验证是通过过滤器来处理的. 默认情况下Spring会帮我们注册了很多过滤器, 注册的顺序如下(打星星的是重点):
ChannelProcessingFilter
* SecurityContextPersistenceFilter
ConcurrentSessionFilter
LogoutFilter
* UsernamePasswordAuthenticationFilter/CasAuthenticationFilter/BasicAuthenticationFilter
SecurityContextHolderAwareRequestFilter
JaasApiIntegrationFilter
RememberMeAuthenticationFilter
AnonymousAuthenticationFilter
* ExceptionTranslationFilter
* FilterSecurityInterceptor

SecurityContextPersistenceFilter

用来建立和保存SecurityContext, 在整个request过程中跟踪请求者的认证信息. 当一个request完成时, 它也负责删除SecurityContextHolder里的内容.

UsernamePasswordAuthenticationFilter

用来处理表单(通常是登录表单)提交时的验证. 这个过滤器一般要自己手动配置一下, 如下:

<sec:custom-filter ref="loginAuthenticationFilter" position="FORM_LOGIN_FILTER"/>
<bean id="loginAuthenticationFilter" class="org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter">
<property name="filterProcessesUrl" value="/my_login"></property> <!-- 表单提交的url, 默认是/j_spring_security_check -->
<property name="usernameParameter" value="my_username"></property> <!-- 表单里用户名字段的name, 默认是j_username -->
<property name="passwordParameter" value="my_password"></property> <!-- 表单里密码字段的name, 默认是j_password -->
<property name="authenticationManager" ref="authenticationManager"/> <!-- 一定要配置, 这里使用上面定义的authenticationManager -->
<property name="authenticationFailureHandler" ref="authenticationFailureHandler"/> <!-- 验证失败时的处理器 -->
<property name="authenticationSuccessHandler" ref="authenticationSuccessHandler"/> <!-- 验证成果时的处理器 -->
</bean>
<bean id="authenticationSuccessHandler" class="org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler">
<property name="defaultTargetUrl" value="/index"/> <!-- 验证成功时跳到哪个请求 -->
<property name="alwaysUseDefaultTargetUrl" value="true"/>
</bean>

<bean id="authenticationFailureHandler" class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
<property name="defaultFailureUrl" value="/login"/> <!-- 验证失败时跳到哪个请求 -->
</bean>

上面的配置比较繁琐, 可以用<form-login>标签简化配置:

<sec:http auto-config="true" entry-point-ref="authenticationEntryPointAdapter">
...

<sec:form-login login-page="login.ftl" username-parameter="my_username" password-parameter ="my_password"
authentication-failure-url="/login"
login-processing-url="/my_login"
always-use-default-target="true"
authentication-success-handler-ref="authenticationSuccessHandler"
authentication-failure-handler-ref="authenticationFailureHandler"/>

...
</sec:http>

ExceptionTranslationFilter
这个过滤器不作具体的验证操作. 它用来处理Spring Security框架抛出的异常. 如上文提到, 当抛出一个AccessDeniedException时, 是交给AuthenticationEntryPoint还是AccessDeniedHandler来处理, 就是由ExceptionTranslationFilter决定.

FilterSecurityInterceptor
这个过滤器非常重要. 它负责处理对所有非公有资源请求的安全验证. 配置如下:
<sec:custom-filter ref="filterSecurityInterceptor" <span style="color:#ff0000;"><strong>before="FILTER_SECURITY_INTERCEPTOR"</strong></span>/>

<bean id="filterSecurityInterceptor" class="org.springframework.security.web.access.intercept.FilterSecurityInterceptor">
<property name="authenticationManager" ref="authenticationManager"/>
<property name="accessDecisionManager" ref="accessDecisionManager"/>
<property name="securityMetadataSource" ref="mySecurityMetadataSource"/>
</bean>

<bean id="accessDecisionManager" class="org.springframework.security.access.vote.AffirmativeBased">
<property name="decisionVoters">
<list>
<bean class="org.springframework.security.access.vote.RoleVoter">
<property name="rolePrefix" value=""/>
</bean>
<bean class="org.springframework.security.access.vote.AuthenticatedVoter"/>
</list>
</property>
</bean>

<bean id="mySecurityMetadataSource" class="com.demo.security.MySecurityMetadataSource"></bean>

它的三个属性必须要配置:
authenticationManager: 使用上面定义的authenticationManager
accessDecisionManager: 使用默认的投票器
securityMetadataSource: 用来储存请求与权限的对应关系. 一般要自己重写, 要实现FilterInvocationSecurityMetadataSource接口. 可参考DefaultFilterInvocationSecurityMetadataSource.

FilterInvocationSecurityMetadataSource接口有3个方法:
boolean supports(Class<?> clazz);
Collection<ConfigAttribute> getAllConfigAttributes();
Collection<ConfigAttribute> getAttributes(Object object);

第一个方法不清楚其作用, 一般返回true.
第二个方法是Spring容器启动时自动调用的, 返回所有权限的集合. 一般把所有请求与权限的对应关系也要在这个方法里初始化, 保存在一个属性变量里.
第三个是当接收到一个http请求时, filterSecurityInterceptor会调用的方法. 参数object是一个包含url信息的HttpServletRequest实例. 这个方法要返回请求该url所需要的所有权限集合.

下面来看看filterSecurityInterceptor具体做了些什么吧:
doFilter()方法没什么好说的, 它调用了invoke()方法.

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}

invoke()方法主要看super.beforeInvocation(fi), 它把请求交给下一个filter之前, 验证当前用户有没有权限访问这个请求. 如果没有, 则抛出AccessDeniedException.

public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null) && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} else {
// first time this request being called, so perform security checking
if (fi.getRequest() != null) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}

InterceptorStatusToken token = super.beforeInvocation(fi);

try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} finally {
super.finallyInvocation(token);
}

super.afterInvocation(token, null);
}
}

beforeInvocation()方法主要有三步:
第一步找出该请求所需要的全部权限;
第二步找出当前用户的的全部权限;
第三步验证用户是否满足权限要求.

protected InterceptorStatusToken beforeInvocation(Object object) {
Assert.notNull(object, "Object was null");
final boolean debug = logger.isDebugEnabled();

if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
throw new IllegalArgumentException("Security invocation attempted for object "
+ object.getClass().getName()
+ " but AbstractSecurityInterceptor only configured to support secure objects of type: "
+ getSecureObjectClass());
}

<strong><span style="color:#ff0000;">// 第一步</span>
<span style="color:#ff0000;">Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);</span></strong>

if (attributes == null || attributes.isEmpty()) {
if (rejectPublicInvocations) {
throw new IllegalArgumentException("Secure object invocation " + object +
" was denied as public invocations are not allowed via this interceptor. "
+ "This indicates a configuration error because the "
+ "rejectPublicInvocations property is set to 'true'");
}

if (debug) {
logger.debug("Public object - authentication not attempted");
}

publishEvent(new PublicInvocationEvent(object));

return null; // no further work post-invocation
}

if (debug) {
logger.debug("Secure object: " + object + "; Attributes: " + attributes);
}

if (SecurityContextHolder.getContext().getAuthentication() == null) {
credentialsNotFound(messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
"An Authentication object was not found in the SecurityContext"), object, attributes);
}

<strong><span style="color:#ff0000;">// 第二步</span>
<span style="color:#ff0000;">Authentication authenticated = authenticateIfRequired();</span></strong>

// Attempt authorization
try {
<strong><span style="color:#ff0000;">// 第三步</span></strong>
<span style="color:#ff0000;">this.accessDecisionManager.decide(authenticated, object, attributes);</span>
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, accessDeniedException));

throw accessDeniedException;
}

if (debug) {
logger.debug("Authorization successful");
}

if (publishAuthorizationSuccess) {
publishEvent(new AuthorizedEvent(object, attributes, authenticated));
}

// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);

if (runAs == null) {
if (debug) {
logger.debug("RunAsManager did not change Authentication object");
}

// no further work post-invocation
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);
} else {
if (debug) {
logger.debug("Switching to RunAs Authentication: " + runAs);
}

SecurityContext origCtx = SecurityContextHolder.getContext();
SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
SecurityContextHolder.getContext().setAuthentication(runAs);

// need to revert to token.Authenticated post-invocation
return new InterceptorStatusToken(origCtx, true, attributes, object);
}
}
注意上面红色粗体的部分: before="FILTER_SECURITY_INTERCEPTOR"

它的意思是我们这个自定义的filterSecurityInterceptor是加在系统默认的filterSecurityInterceptor之前的. 意思是说现在Spring里有两个filterSecurityInterceptor. 通过debug我们也看到filterChain里确实有两个:

其中第10个是我们自定义的, 第11个是系统默认的. 在上面的例子中, 系统自动加的filterSecurityInterceptor没有任何用处, 应该删除, 但暂时找不到方法删. 如果把配置改成position="FILTER_SECURITY_INTERCEPTOR"的话, Spring启动时会报错, 错误信息如下:

Configuration problem: Filter beans '<filterSecurityInterceptor>' and '<org.springframework.security.web.access.intercept.FilterSecurityInterceptor#0>' have the same 'order' value. When using custom filters, please make sure the positions do not conflict with default filters. Alternatively you can disable the default filters by removing the corresponding child elements from <http> and avoiding the use of <http auto-config='true'>.

Spring Security 下面简称为 Security 基于 spring-security 4.1

Security 的 WEB 扩展中 form 方式登陆使用的是过滤器方式,页面模版是可以定制的,但是如果需要登陆表单中有更多的选项,或者说需要在登陆的时候处理一些事情就变的很不方便。下面就是教你如何使用一个普通的控制器来完成登陆验证。

首先是制定 Security 的配置文件

在 http 节点中有一个属性 entry-point-ref 可以指定如果需要登陆将如何反应,在实现 AuthenticationEntryPoint 接口的类中有一个名称叫 LoginUrlAuthenticationEntryPoint 的类他可以实现需要登陆的时候产生 URL 跳转。

首先创建一个 LoginUrlAuthenticationEntryPoint bean

<beans:bean id="authenticationEntryPoint" class="org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint">
    <!-- 构造函数中指定需要跳转的 URL -->
    <beans:constructor-arg value="/login" />
</beans:bean>

然后在 http 节点中配置 entry-point-ref
auto-config 可以关闭,因为不需要自动配置

<http auto-config="false" entry-point-ref="authenticationEntryPoint">
    ...
</http>

在 http 节点内添加 intercept-url 防止拦截 /login 链接

<intercept-url pattern="/login" access="permitAll" />

如果需要记住我功能,需要在 http 节点内增加 remember-me 配置

<!-- rememberMe 对应的是表单类中的属性名称 -->
<remember-me remember-me-parameter="rememberMe" />

最后可以增加一个退出过滤器
因为是过滤器拦截判断使用 /logout 不需要有对应的控制器
如果这个 /logout 中没有对应的控制器,需要添加 logout-success-url 跳转链接,防止访问 /logout URL 后候报 404 错误

<logout logout-url="/logout" logout-success-url="/" />

最后需要配置 authentication-manager 节点中的 id 用来给控制器中注入使用

<authentication-manager id="authenticationManager">
   ...
</authentication-manager>

完整的配置文件代码

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns="http://www.springframework.org/schema/security"
    xmlns:beans="http://www.springframework.org/schema/beans" 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.xsd


http://www.springframework.org/schema/security

        http://www.springframework.org/schema/security/spring-security.xsd">
    
    <!-- 本配置文件完全使用自定的控制器来完成登陆退出操作 Yefei -->
    <!-- 开启 Spring Security 调试模式
    <debug />
    -->
    <!-- 是否开启注解支持,例如: @Secured
    <global-method-security secured-annotations="enabled" />
    -->
    <!-- 配置不需要安全过滤的页面 -->
    <http pattern="/static/**" security="none" />
    <beans:bean id="authenticationEntryPoint"
        class="org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint">
        <beans:constructor-arg value="/login" />
    </beans:bean>
    <http auto-config="false"
        entry-point-ref="authenticationEntryPoint">
        <intercept-url pattern="/" access="permitAll"/>
        <intercept-url pattern="/login" access="permitAll" />
        <intercept-url pattern="/admin/**" access="hasRole('ADMIN')" />
        <intercept-url pattern="/**" access="hasRole('USER')" />
        <!-- 用于 cookie 登陆 remember-me-parameter 中的值必须和表单中的 rememberMe name 一致 -->
        <remember-me remember-me-parameter="rememberMe" />
        <!-- logout 可以使用简单的过滤器完成, 启用了 CSRF 必须使用 POST 退出 -->
        <logout logout-url="/logout" logout-success-url="/" />
    </http>
    <authentication-manager id="authenticationManager">
        <authentication-provider>
            <user-service>
                <user name="admin" authorities="ROLE_USER,ROLE_ADMIN" password="123456" />
                <user name="user" authorities="ROLE_USER" password="123456" />
            </user-service>
        </authentication-provider>
    </authentication-manager>
</beans:beans>

下面是控制器的代码部分

代码过程都是通过 debug 模式下在 UsernamePasswordAuthenticationFilter 中分析得出

@Controller
public class AuthController {
    @Autowired
    @Qualifier("authenticationManager") // bean id 在 <authentication-manager> 中设置
    private AuthenticationManager authManager;
    @Autowired
    private SessionAuthenticationStrategy sessionStrategy;
 
    @Autowired(required = false)
    private RememberMeServices rememberMeServices;
 
    @Autowired(required = false)
    private ApplicationEventPublisher eventPublisher;
 
    @RequestMapping(value = "/login", method = RequestMethod.GET)
    public String login(LoginForm form) {
        return "login";
    }
 
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    public String loginPost(@Valid LoginForm form, BindingResult result,
            HttpServletRequest request, HttpServletResponse response) {
        if (!result.hasErrors()) {
 
            // 创建一个用户名密码登陆信息
            UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(form.getUsername(), form.getPassword());
 
            try {
                // 用户名密码登陆效验
                Authentication authResult = authManager.authenticate(token);
 
                // 在 session 中保存 authResult
                sessionStrategy.onAuthentication(authResult, request, response);
 
                // 在当前访问线程中设置 authResult
                SecurityContextHolder.getContext().setAuthentication(authResult);
 
                // 如果记住我在配置文件中有配置
                if (rememberMeServices != null) {
                    rememberMeServices.loginSuccess(request, response, authResult);
                }
 
                // 发布登陆成功事件
                if (eventPublisher != null) {
                    eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
                }
                return "redirect:/";
            } catch (AuthenticationException e) {
                result.reject("authentication.exception", e.getLocalizedMessage());
            }
        }
        return "login";
    }
}

最后是 JSP 页面部分代码

页面都需要经过 Security 过滤器才能产生 csrf token,否则请关闭 csrf

登陆代码

<form:form commandName="loginForm" method="POST">
    <form:errors path="" />
    <p>用户名:<form:input path="username"/> <form:errors path="username" /></p>
    <p>密码:<form:input path="password"/> <form:errors path="password" /></p>
    <p>记住我:<form:checkbox path="rememberMe"/> <form:errors path="rememberMe" /></p>
    <button type="submit">登陆</button>
</form:form>

退出代码

<form action="/logout" method="POST">
    <sec:csrfInput/>
    <button>退出</button>
</form>

Spring Security中设置entry-point-ref="第三方登录入口"的作用
2017年05月26日 17:23:03 张Roc 阅读数:4135 标签: spring security 更多
个人分类: spring-security
在Spring Security中,通过设置entry-point-ref="第三方登录入口",可以在访问系统首页的时候进行登录跳转。如

<http pattern="/**" entry-point-ref="casEntryPoint">
.....
</http>
它在系统进行登录认证的过程会进行认证,认证不通过则抛出一个异常给ExceptionTranslationFilter,由它进行通过entry-point-ref设置的入口点进行处理。

所以按照SpringSecurity的过滤器的顺序可判断,其异常是由FilterSecurityInterceptor抛出的。经过层层推进,我们在其抽象类AbstractSecurityInterceptor中的beforeInvocation方法中找到了抛出异常的地方,其调用了authenticateIfRequired方法,方法如下

 private Authentication authenticateIfRequired() {
 Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 
 if (authentication.isAuthenticated() && !alwaysReauthenticate) {
 if (logger.isDebugEnabled()) {
 logger.debug("Previously Authenticated: " + authentication);
 }
 return authentication;
 }
 
 authentication = authenticationManager.authenticate(authentication);
 // We don't authenticated.setAuthentication(true), because each provider should do that
 if (logger.isDebugEnabled()) {
 logger.debug("Successfully Authenticated: " + authentication);
 }
 SecurityContextHolder.getContext().setAuthentication(authentication);
 return authentication;
 }

但是,让人很奇怪的是SecurityContext中的authentication是从哪来的,我们还没有登录呀。不要着急,让我们接下来一探究竟。
我们知道,在SpringSecurity的过滤链中存在一个匿名登录的过滤器,AnonymousAuthenticationFilter,你可以添加anonymous元素进行设置,但是默认可以不加。而默认后会对AnonymousAuthenticationFilter的一些属性进行设置,并将其返回到过滤链中,所以这个请求是要经过匿名过滤器的。所以让我们来看一下它的doFilter方法

 public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
 throws IOException, ServletException {
 if (applyAnonymousForThisRequest((HttpServletRequest) req)) {
 if (SecurityContextHolder.getContext().getAuthentication() == null) {
 //这就是设置authentication的地方
 SecurityContextHolder.getContext().setAuthentication(createAuthentication((HttpServletRequest) req));
 if (logger.isDebugEnabled()) {
 logger.debug("Populated SecurityContextHolder with anonymous token: '"
 + SecurityContextHolder.getContext().getAuthentication() + "'");
 }
 } else {
 if (logger.isDebugEnabled()) {
 logger.debug("SecurityContextHolder not populated with anonymous token, as it already contained: '"
 + SecurityContextHolder.getContext().getAuthentication() + "'");
 }
 }
 }
 
 chain.doFilter(req, res);
 }

可以看出,我们确实对SecurityContext设置了authentication,所以在之后的FilterSecurityInterceptor中才会得到,之后才会有对authentication的认证。如何认证的就不加以描述了。


---------------------
spring-security整合CAS

 众所周知,Cas是对单点登录的一种实现。本文假设读者已经了解了Cas的原理及其使用,这些内容在本文将不会讨论。Cas有Server端和Client端,Client端通常对应着我们自己的应用,Spring Security整合Cas指的就是在Spring Security应用中整合Cas Client,已达到使用Cas Server实现单点登录和登出的效果。本文旨在描述如何在Spring Security应用中使用Cas的单点登录。

 首先需要将Spring Security对Cas支持的jar包加入到应用的类路径中。如果我们的应用使用Maven构造的,则可以在应用的pom.xml文件中加上如下依赖。
 <dependency>
 <groupId>org.springframework.security</groupId>
 <artifactId>spring-security-cas</artifactId>
 <version>${spring.security.version}</version>
 </dependency>

1.1 配置登录认证
 加入了spring-security-cas-xxx.jar到Spring Security应用的classpath后,我们便可以开始配置我们的Spring Security应用使用Cas进行单点登录了。
1.1.1配置AuthenticationEntryPoint
 首先需要做的是将应用的登录认证入口改为使用CasAuthenticationEntryPoint。所以首先我们需要配置一个CasAuthenticationEntryPoint对应的bean,然后指定需要进行登录认证时使用该AuthenticationEntryPoint。配置CasAuthenticationEntryPoint时需要指定一个ServiceProperties,该对象主要用来描述service(Cas概念)相关的属性,主要是指定在Cas Server认证成功后将要跳转的地址。
 <!-- 指定登录入口为casEntryPoint -->
 <security:http entry-point-ref="casEntryPoint">
 ...
 </security:http>

 <!-- 认证的入口 -->
 <bean id="casEntryPoint"
 class="org.springframework.security.cas.web.CasAuthenticationEntryPoint">
 <!-- Cas Server的登录地址,elim是我的计算机名 -->
 <property name="loginUrl" value="https://elim:8443/cas/login" />
 <!-- service相关的属性 -->
 <property name="serviceProperties" ref="serviceProperties" />
 </bean>

 <!-- 指定service相关信息 -->
 <bean id="serviceProperties"class="org.springframework.security.cas.ServiceProperties">
 <!-- Cas Server认证成功后的跳转地址,这里要跳转到我们的Spring Security应用,之后会由CasAuthenticationFilter处理,默认处理地址为/j_spring_cas_security_check -->
 <property name="service"
 value="http://elim:8080/app/j_spring_cas_security_check" />
 </bean>

1.1.2配置CasAuthenticationFilter
 之后我们需要配置一个CasAuthenticationFilter,并将其放置在Filter链表中CAS_FILTER的位置,以处理Cas Server认证成功后的页面跳转,用以在Spring Security中进行认证。该Filter会将Cas Server传递过来的ticket(Cas概念)封装成一个Authentication(对应UsernamePasswordAuthenticationToken,其中ticket作为该Authentication的password),然后传递给AuthenticationManager进行认证。
 <security:http entry-point-ref="casEntryPoint">
 ...
 <security:custom-filter ref="casFilter" position="CAS_FILTER"/>
 ...
 </security:http>

 <bean id="casFilter"
 class="org.springframework.security.cas.web.CasAuthenticationFilter">
 <property name="authenticationManager" ref="authenticationManager" />
 <!-- 指定处理地址,不指定时默认将会是“/j_spring_cas_security_check” -->
 <property name="filterProcessesUrl" value="/j_spring_cas_security_check"/>
 </bean>

1.1.3配置AuthenticationManager
 CasAuthenticationFilter会将封装好的包含Cas Server传递过来的ticket的Authentication对象传递给AuthenticationManager进行认证。我们知道默认的AuthenticationManager实现类为ProviderManager,而ProviderManager中真正进行认证的是AuthenticationProvider。所以接下来我们要在AuthenticationManager中配置一个能够处理CasAuthenticationFilter传递过来的Authentication对象的AuthenticationProvider实现,CasAuthenticationProvider。CasAuthenticationProvider首先会利用TicketValidator(Cas概念)对Authentication中包含的ticket信息进行认证。认证通过后将利用持有的AuthenticationUserDetailsService根据认证通过后回传的Assertion对象中拥有的username加载用户对应的UserDetails,即主要是加载用户的相关权限信息GrantedAuthority。然后构造一个CasAuthenticationToken进行返回。之后的逻辑就是正常的Spring Security的逻辑了。
 <security:authentication-manager alias="authenticationManager">
 <security:authentication-provider ref="casAuthenticationProvider"/>
 </security:authentication-manager>

 <bean id="casAuthenticationProvider"
 class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
 <!-- 通过username来加载UserDetails -->
 <property name="authenticationUserDetailsService">
 <beanclass="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
 <!-- 真正加载UserDetails的UserDetailsService实现 -->
 <constructor-arg ref="userDetailsService" />
 </bean>
 </property>
 <property name="serviceProperties" ref="serviceProperties" />
 <!-- 配置TicketValidator在登录认证成功后验证ticket -->
 <property name="ticketValidator">
 <bean class="org.jasig.cas.client.validation.Cas20ServiceTicketValidator">
 <!-- Cas Server访问地址的前缀,即根路径-->
 <constructor-arg index="0" value="https:// elim:8443/cas" />
 </bean>
 </property>
 <property name="key" value="key4CasAuthenticationProvider" />
 </bean>

 <bean id="userDetailsService"
 class="org.springframework.security.core.userdetails.jdbc.JdbcDaoImpl">
 <property name="dataSource" ref="dataSource" />
 </bean>

 经过以上三步配置以后,我们的Spring Security应用就已经跟Cas整合好了,可以在需要登录的时候通过Cas Server进行单点登录了。
1.2 单点登出
 Spring Security应用整合Cas Client配置单点登出功能实际和单独使用Cas Client配置单点登出功能一样,其根本都是通过配置一个SingleSignOutFilter响应Cas Server单点登出时的回调,配置一个SingleSignOutHttpSessionListener用于在Session过期时删除SingleSignOutFilter存放的对应信息。SingleSignOutFilter需要配置在Cas 的AuthenticationFilter之前,对于Spring Security应用而言,该Filter通常是配置在Spring Security的配置文件中,而且是配置在CAS_FILTER之前。所以我们可以在Spring Security的配置文件中进行如下配置。
 <security:http entry-point-ref="casEntryPoint">
 <!-- SingleSignOutFilter放在CAS_FILTER之前 -->
 <security:custom-filter ref="casLogoutFilter" before="CAS_FILTER"/>
 <security:custom-filter ref="casFilter" position="CAS_FILTER"/>
 ...
 </security:http>

 <bean id="casLogoutFilter" class="org.jasig.cas.client.session.SingleSignOutFilter"/>
 然后跟单独使用Cas Client一样,在web.xml文件中配置一个SingleSignOutHttpSessionListener。
 <listener>
 <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener</listener-class>
 </listener>
 经过以上配置在访问Cas Server的logout地址(如:https:elim:8443/cas/logout)进行登出时,Cas Server登出后将回调其中注册的每一个Service(Cas概念,即client应用),此时在client应用中配置好的SingleSignOutFilter将处理对应Client应用的登出操作。
 虽然以上配置可以满足我们在Spring Security应用中的单点登出要求,但Cas官方文档和Spring Security官方文档都推荐我们在Cas Client应用进行登出操作时,不是直接访问Cas Server的logout,而是先登出本应用,然后告诉用户其当前登出的只是本应用,再提供一个对应Cas Server的链接,使其可以进行真正的单点登出。对此,Spring Security官方文档中给我们提供例子是提供两个LogoutFilter,一个是登出当前Spring Security应用,一个是登出Cas Server的。
 <security:http entry-point-ref="casEntryPoint">
 <!-- 请求登出Cas Server的过滤器,放在Spring Security的登出过滤器之前 -->
 <security:custom-filter ref="requestCasLogoutFilter" before="LOGOUT_FILTER"/>
 <!-- SingleSignOutFilter放在CAS_FILTER之前 -->
 <security:custom-filter ref="casLogoutFilter" before="CAS_FILTER"/>
 <security:custom-filter ref="casFilter" position="CAS_FILTER"/>
 ...
 </security:http>

 <bean id="requestCasLogoutFilter"class="org.springframework.security.web.authentication.logout.LogoutFilter">
 <!-- 指定登出成功后需要跳转的地址,这里指向Cas Server的登出URL,以实现单点登出 -->
 <constructor-arg value="https://elim:8443/cas/logout"/>
 <constructor-arg>
 <beanclass="org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler"/>
 </constructor-arg>
 <!-- 该Filter需要处理的地址,默认是Spring Security的默认登出地址“/j_spring_security_logout”-->
 <property name="filterProcessesUrl" value="/j_spring_cas_security_logout"/>
 </bean>
 此外,Spring Security推荐我们在使用Cas Server的单点登出时一起使用CharacterEncodingFilter,以避免SingleSignOutFilter在获取参数时出现编码问题。

1.3 使用代理
 关于Cas应用使用代理的基本原理、概念等内容的介绍不在本文讨论范围之内,如需了解请读者参考其它资料,或者参考我的另一篇博文。本文旨在描述Spring Security应用在整合Cas后如何通过Cas Proxy访问另一个受Cas包含的应用。
 使用Cas Proxy时有两个主体,代理端和被代理端。而且我们知道代理端和被代理端针对Cas20ProxyReceivingTicketValidationFilter的配置是不一样的,虽然整合Cas的Spring Security应用不再使用Cas20ProxyReceivingTicketValidationFilter了,但其底层的核心机制是一样的。所以Cas整合Spring Security后的应用在作为代理端和被代理端时的配置也是不一样的。接下来将分开讲解Spring Security应用作为代理端和被代理端整合Cas后的配置。

1.3.1代理端
 首先需要为CasAuthenticationFilter多指定两个参数,proxyReceptorUrl和proxyGrantingTicketStorage。proxyReceptorUrl用以指定Cas Server在回调代理端传递pgtId和pgtIou时回调地址相对于代理端的路径,如“/proxyCallback”,CasAuthenticationFilter会根据proxyReceptorUrl来确定一个请求是否来自Cas Server针对proxy的回调。如果是则需要接收Cas Server传递过来的pgtId和pgtIou,并将它们保存在持有的ProxyGrantingTicketStorage中。CasAuthenticationProvider之后会从ProxyGrantingTicketStorage中获取对应的pgtId,即proxy granting ticket,并将其保存在AttributePrincipal中,而AttributePrincipal又会保存到对应的Assertion中。
 <!-- 配置ProxyGrantingTicketStorage,用以保存pgtId和pgtIou -->
 <bean id="proxyGrantingTicketStorage"class="org.jasig.cas.client.proxy.ProxyGrantingTicketStorageImpl"/>
 <bean id="casFilter"
 class="org.springframework.security.cas.web.CasAuthenticationFilter">
 <property name="authenticationManager" ref="authenticationManager" />
 <!-- 指定处理地址,不指定时默认将会是“/j_spring_cas_security_check” -->
 <property name="filterProcessesUrl" value="/j_spring_cas_security_check"/>
 <property name="proxyGrantingTicketStorage" ref="proxyGrantingTicketStorage"/>
 <property name="proxyReceptorUrl" value="/proxyCallback"/>
 </bean>

 其次是需要将CasAuthenticationProvider持有的TicketValidator由Cas20ServiceTicketValidator改成Cas20ProxyTicketValidator。其需要配置一个ProxyGrantingTicketStorage用来获取proxy granting ticket,即我们熟知的pgtId。在单独使用Cas Proxy时,Cas20ProxyReceivingTicketValidationFilter内部默认持有一个ProxyGrantingTicketStorage实现,其使用的Cas20ProxyTicketValidator也将使用该ProxyGrantingTicketStorage。整合Spring Security之后, Spring Security不使用Cas20ProxyReceivingTicketValidationFilter,而直接由CasAuthenticationFilter获取proxy granting ticket,由CasAuthenticationProvider对ticket进行校验。Cas20ProxyTicketValidator内部没默认的ProxyGrantingTicketStorage,所以在配置Cas20ProxyTicketValidator时我们需要给其指定一个ProxyGrantingTicketStorage实现。此外还需要为Cas20ProxyTicketValidator指定一个proxyCallbackUrl用以指定在Cas20ProxyTicketValidator通过Cas Server校验service ticket成功后将回调哪个地址以传递pgtId和pgtIou。proxyCallbackUrl默认情况下必须使用https协议,而应用的其它请求可以用非https协议。其它的配置和Cas20ServiceTicketValidator一样,Cas20ProxyTicketValidator的父类其实就是Cas20ServiceTicketValidator。
 <bean id="casAuthenticationProvider"
 class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
 <!-- 通过username来加载UserDetails -->
 <property name="authenticationUserDetailsService">
 <beanclass="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
 <!-- 真正加载UserDetails的UserDetailsService实现 -->
 <constructor-arg ref="userDetailsService" />
 </bean>
 </property>
 <property name="serviceProperties" ref="serviceProperties" />
 <!-- 配置TicketValidator在登录认证成功后验证ticket -->
 <property name="ticketValidator">
 <bean class="org.jasig.cas.client.validation.Cas20ProxyTicketValidator">
 <!-- Cas Server访问地址的前缀,即根路径-->
 <constructor-arg index="0" value="https://elim:8443/cas" />
 <!-- 指定Cas Server回调传递pgtId和pgtIou的地址,该地址必须使用https协议 -->
 <property name="proxyCallbackUrl"value="https://elim:8043/app/proxyCallback"/>
 <property name="proxyGrantingTicketStorage" ref="proxyGrantingTicketStorage"/>
 </bean>
 </property>
 <property name="key" value="key4CasAuthenticationProvider" />
 </bean>
 经过以上步骤后我们整合Cas后的Spring Security应用就可以作为代理端使用Cas proxy访问其它被Cas保护的应用了,当然前提是其它被代理端能够接受我们应用的代理,了解Cas Proxy的人应该都知道这一点,在接下来的Spring Security应用整合Cas作为被代理端中也会讲到这部分内容。这里我们假设现在有一个应用app2能够接受我们应用的代理访问,那么在基于上述配置的应用中我们可以通过如下代码访问app2。
@Controller
@RequestMapping("/cas/test")
publicclass CasTestController {
 @RequestMapping("/getData")
 publicvoid getDataFromApp(PrintWriter writer) throws Exception {
 //1、从SecurityContextHolder获取到当前的Authentication对象,其是一个CasAuthenticationToken
 CasAuthenticationToken cat = (CasAuthenticationToken)SecurityContextHolder.getContext().getAuthentication();
 //2、获取到AttributePrincipal对象
 AttributePrincipal principal = cat.getAssertion().getPrincipal();
 //3、获取对应的proxy ticket
 String proxyTicket = principal.getProxyTicketFor("http://elim:8081/app2/getData.jsp");
 //4、请求被代理应用时将获取到的proxy ticket以参数ticket进行传递
 URL url = new URL("http://elim:8081/app2/getData.jsp?ticket=" + URLEncoder.encode(proxyTicket, "UTF-8"));
 HttpURLConnection conn = (HttpURLConnection)url.openConnection();
 BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(),"UTF-8"));
 StringBuffer content = new StringBuffer();
 String line = null;
 while ((line=br.readLine()) != null) {
 content.append(line).append("<br/>");
 }
 writer.write(content.toString());
 }
}
 需要注意的是通过AttributePrincipal的getProxyTicketFor()方法获取proxy ticket时,每调用一次都会获取一个全新的proxy ticket。用户可以根据自己的需要将获取到的proxy ticket按照指定的URL缓存起来,以避免每次都去针对同一个URL获取一个全新的proxy ticket。此外,如果在被代理端认证时根据proxy ticket缓存了Authentication的话也需要我们在代理端保证针对同一URL传递过去的proxy ticket是一样的,否则被代理端针对proxy ticket缓存Authentication的功能就没用了。
1.3.2被代理端
 Spring Security应用整合Cas使用Cas Proxy作为被代理端时主要需要进行三点修改
 第一点是通过ServiceProperties指定CasAuthenticationFilter的authenticateAllArtifacts为true,这样CasAuthenticationFilter将会尝试对所有ticket进行认证,而不是只认证来自filterProccessUrl指定地址的请求。这样代理端在请求被代理端的资源时将proxy ticket以参数ticket进行传递时,CasAuthenticationFilter才会让CasAuthenticationProvider对proxy ticket进行校验,这样我们的请求才有可能被CasAuthenticationProvider认证成功并请求到真正的资源。
 第二点是指定CasAuthenticationFilter使用的AuthenticationDetailsSource为ServiceAuthenticationDetailsSource。CasAuthenticationFilter默认使用的是WebAuthenticationDetailsSource。ServiceAuthenticationDetailsSource将构建一个ServiceAuthenticationDetails对象作为当前Authentication的details对象。ServiceAuthenticationDetailsSource构建的ServiceAuthenticationDetails对象会将当前请求的地址构建为一个serviceUrl,通过其getServiceUrl()方法可以获取到该serviceUrl地址。之后该Authentication对象传递到CasAuthenticationProvider进行认证时就可以从Authentication的details中获取到对应的serviceUrl,并在通过Cas Server对代理端以参数ticket传递过来的proxy ticket进行验证时连同对应的serviceUrl一起传递过去。因为之前代理端申请proxy ticket时就是通过该serviceUrl进行申请的,Cas Server需要对于它们的配对来验证对应的proxy ticket是否有效。
 第三点是将CasAuthenticationProvider的TicketValidator由Cas20ServiceTicketValidator改为Cas20ProxyTicketValidator,因为Cas Proxy被代理端需要调用Cas Server的proxyValidator对代理端传递过来的proxy ticket进行验证。此外需要通过acceptAnyProxy或allowedProxyChains指定将接受哪些代理。acceptAnyProxy用以指定是否接受所有的代理,可选值为true或false。allowedProxyChains则用以指定具体接受哪些代理,其对应的值是代理端在获取pgtId时提供给Cas Server的回调地址,如我们需要接受前面示例中代理端的代理,则我们的allowedProxyChains的值应该是“https://elim:8043/app/proxyCallback”。如果需要接受多个代理端的代理,则在指定allowedProxyChains时多个代理端回调地址应各占一行。
 针对以上三点,我们的Spring Security应用整合Cas作为Cas Proxy的被代理端时需要对我们的配置进行如下改造。
 <!-- 指定service相关信息 -->
 <bean id="serviceProperties"class="org.springframework.security.cas.ServiceProperties">
 <!-- Cas Server认证成功后的跳转地址,这里要跳转到我们的Spring Security应用,之后会由CasAuthenticationFilter处理,默认处理地址为/j_spring_cas_security_check -->
 <property name="service"
 value="http://elim:8083/app2/j_spring_cas_security_check" />
 <!-- 通过ServiceProperties指定CasAuthenticationFilter的authenticateAllArtifacts为true -->
 <property name="authenticateAllArtifacts" value="true"/>
 </bean>
 <bean id="casFilter"
 class="org.springframework.security.cas.web.CasAuthenticationFilter">
 <property name="authenticationManager" ref="authenticationManager" />
 <!-- 指定处理地址,不指定时默认将会是“/j_spring_cas_security_check” -->
 <property name="filterProcessesUrl" value="/j_spring_cas_security_check" />
 <!-- 通过ServiceProperties指定CasAuthenticationFilter的authenticateAllArtifacts为true -->
 <property name="serviceProperties" ref="serviceProperties" />
 <!-- 指定使用的AuthenticationDetailsSource为ServiceAuthenticationDetailsSource -->
 <property name="authenticationDetailsSource">
 <beanclass="org.springframework.security.cas.web.authentication.ServiceAuthenticationDetailsSource"/>
 </property>
 </bean>
 <bean id="casAuthenticationProvider"
 class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
 <!-- 通过username来加载UserDetails -->
 <property name="authenticationUserDetailsService">
 <beanclass="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
 <!-- 真正加载UserDetails的UserDetailsService实现 -->
 <constructor-arg ref="userDetailsService" />
 </bean>
 </property>
 <property name="serviceProperties" ref="serviceProperties" />
 <!-- 配置TicketValidator在登录认证成功后验证ticket -->
 <property name="ticketValidator">
 <bean class="org.jasig.cas.client.validation.Cas20ProxyTicketValidator">
 <!-- Cas Server访问地址的前缀,即根路径-->
 <constructor-arg index="0" value="https://elim:8443/cas" />
 <property name="allowedProxyChains">
 <value>https://elim:8043/app/proxyCallback</value>
 </property>
 </bean>
 </property>
 <property name="key" value="key4CasAuthenticationProvider" />
 </bean>

 此外,对于被代理端而言,代理端在对其进行访问时都被认为是无状态的。对于无状态的认证CasAuthenticationProvider将在认证成功后将对应的Authentication对象以proxy tickit为key存放到所持有的StatelessTicketCache中,然后在下次代理端访问时将优先根据代理端传递过来的proxy ticket从StatelessTicketCache中获取Authentication对象,如果存在则不再进行认证,否则将继续进行认证。CasAuthenticationProvider默认持有的StatelessTicketCache为NullStatelessTicketCache,其所有的实现都是空的。所以默认情况下,被代理端在被代理端访问时将每次都对代理端进行认证。如果用户不希望在被代理端每次都对代理端的请求进行认证,则可以为被代理端的CasAuthenticationProvider指定一个StatelessTicketCache。用户可以实现自己的StatelessTicketCache,并指定CasAuthenticationProvider使用的StatelessTicketCache为该StatelessTicketCache。不过也可以使用Spring Security为我们提供的EhCacheBasedTicketCache。EhCacheBasedTicketCache是基于Ehcache实现的一个StatelessTicketCache。以下是一个为CasAuthenticationProvider配置EhCacheBasedTicketCache的示例。
 <bean id="casAuthenticationProvider"
 class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
……
 <property name="statelessTicketCache">
 <beanclass="org.springframework.security.cas.authentication.EhCacheBasedTicketCache">
 <!-- Ehcache对象 -->
 <property name="cache" ref="proxyTicketCache"/>
 </bean>
 </property>
……
 </bean>
 <!-- 定义一个Ehcache -->
 <bean id="proxyTicketCache"class="org.springframework.cache.ehcache.EhCacheFactoryBean">
 <property name="cacheName" value="proxyTicketCache" />
 <property name="timeToLive" value="600"/>
 </bean>

 需要注意的是在代理端通过AttributePrincipal的getProxyTicketFor()方法获取到的proxy ticket每次都是不一样的,所以在被代理端通过StatelessTicketCache根据proxy ticket缓存认证对象Authentication时只有在同一proxy ticket能够请求多次的情况下才会有用,这也就要求我们在代理端同样能将proxy ticket缓存起来,以在请求同一地址时能使用相同的proxy ticket。

1.3.3既为代理端又为被代理端
 Cas Proxy的代理端和被代理端是相互独立的,所以一个Cas应用既可以作为代理端去访问其它Cas应用,也可以作为被代理端被其它应用访问。当Spring Security应用整合Cas后既想作为Cas Proxy的代理端访问其它Cas应用,也想作为被代理端被其它Cas应用访问时只需要将上述作为代理端的配置和作为被代理端的配置整到一起就行了。以下是一段示例代码。
 <!-- 指定service相关信息 -->
 <bean id="serviceProperties"class="org.springframework.security.cas.ServiceProperties">
 <!-- Cas Server认证成功后的跳转地址,这里要跳转到我们的Spring Security应用,之后会由CasAuthenticationFilter处理,默认处理地址为/j_spring_cas_security_check -->
 <property name="service"
 value="http://elim:8080/app /j_spring_cas_security_check" />
 <property name="authenticateAllArtifacts" value="true"/>
 </bean>

 <!-- 配置ProxyGrantingTicketStorage,用以保存pgtId和pgtIou -->
 <bean id="proxyGrantingTicketStorage"class="org.jasig.cas.client.proxy.ProxyGrantingTicketStorageImpl"/>
 <bean id="casFilter"
 class="org.springframework.security.cas.web.CasAuthenticationFilter">
 <property name="authenticationManager" ref="authenticationManager" />
 <!-- 指定处理地址,不指定时默认将会是“/j_spring_cas_security_check” -->
 <property name="filterProcessesUrl" value="/j_spring_cas_security_check"/>
 <property name="proxyGrantingTicketStorage" ref="proxyGrantingTicketStorage"/>
 <property name="proxyReceptorUrl" value="/proxyCallback"/>
 <!-- 通过ServiceProperties指定CasAuthenticationFilter的authenticateAllArtifacts为true -->
 <property name="serviceProperties" ref="serviceProperties" />
 <!-- 指定使用的AuthenticationDetailsSource为ServiceAuthenticationDetailsSource -->
 <property name="authenticationDetailsSource">
 <beanclass="org.springframework.security.cas.web.authentication.ServiceAuthenticationDetailsSource"/>
 </property>
 </bean>
 <bean id="casAuthenticationProvider"
 class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
 <!-- 通过username来加载UserDetails -->
 <property name="authenticationUserDetailsService">
 <beanclass="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
 <!-- 真正加载UserDetails的UserDetailsService实现 -->
 <constructor-arg ref="userDetailsService" />
 </bean>
 </property>
 <property name="serviceProperties" ref="serviceProperties" />
 <!-- 配置TicketValidator在登录认证成功后验证ticket -->
 <property name="ticketValidator">
 <bean class="org.jasig.cas.client.validation.Cas20ProxyTicketValidator">
 <!-- Cas Server访问地址的前缀,即根路径-->
 <constructor-arg index="0" value="https://elim:8443/cas" />
 <!-- 指定Cas Server回调传递pgtId和pgtIou的地址,该地址必须使用https协议 -->
 <property name="proxyCallbackUrl"value="https://elim:8043/app/proxyCallback"/>
 <property name="proxyGrantingTicketStorage" ref="proxyGrantingTicketStorage"/>
 <!-- 作为被代理端时配置接收任何代理 -->
 <property name="acceptAnyProxy" value="true"/>
 </bean>
 </property>
 <property name="key" value="key4CasAuthenticationProvider" />
 </bean>


 关于Cas的更多内容可以参考Cas的官方文档,或者参考我的其它关于Cas的博客。