保护Web应用
Spring Security简介
Spring Security是为基于Spring的应用程序提供声明式安全保护的安全性框架。Spring Security提供了完整的安全性解决方案,它能够在Web请求级别和方法调用级别处理身份认证和授权。因为基于Spring框架,所以Spring Security充分利用了依赖注入(dependency injection,DI)和面向切面的技术。
Spring Security从两个角度来解决安全性问题。它使用Servlet规范中的Filter保护Web请求并限制URL级别的访问。Spring Security还能够使用Spring AOP保护方法调用——借助于对象代理和使用通知,能够确保只有具备适当权限的用户才能访问安全保护的方法。
理解Spring Security的模块
Spring Security 3.2分为11个模块:
| 模 块 | 描 述 |
|---|---|
| ACL | 支持通过访问控制列表(access control list,ACL)为域对象提供安全性 |
| 切面(Aspects) | 一个很小的模块,当使用Spring Security注解时,会使用基于AspectJ的切面,而不是使用标准的Spring AOP |
| CAS客户端(CASClient) | 提供与Jasig的中心认证服务(Central Authentication Service,CAS)进行集成的功能 |
| 配置(Configuration) | 包含通过XML和Java配置Spring Security的功能支持 |
| 核心(Core) | 提供Spring Security基本库 |
| 加密(Cryptography) | 提供了加密和密码编码的功能 |
| LDAP | 支持基于LDAP进行认证 |
| OpenID | 支持使用OpenID进行集中式认证 |
| Remoting | 提供了对Spring Remoting的支持 |
| 标签库(Tag Library) | Spring Security的JSP标签库 |
| Web | 提供了Spring Security基于Filter的Web安全性支持 |
应用程序的类路径下至少要包含Core和Configuration这两个模块。Spring Security经常被用于保护Web应用,这显然也是Spittr应用的场景,所以我们还需要添加Web模块。同时还会用到Spring Security的JSP标签库,所以需要将这个模块也添加进来。
过滤Web请求
DelegatingFilterProxy是一个特殊的Servlet Filter,它本身所做的工作并不多。只是将工作委托给一个javax.servlet.Filter实现 类,这个实现类作为一个注册在Spring应用的上下文中,如图9所示:DelegatingFilterProxy把Filter的处理逻辑委托给Spring应用上下文中所定义的一个代理Filter bean
如果喜欢在传统的web.xml中配置Servlet和Filter的话,可以使用元素,如下所示:
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>
org.springframework.web.filter.DelegatingFilterProxy
</filter-class>
</filter>
在这里,最重要的是设置成了springSecurityFilterChain。这是因为马上就会将Spring Security配置在Web安全性之中,这里会有一个名为springSecurityFilterChain的Filter bean,DelegatingFilterProxy会将过滤逻辑委托给它。
如果希望借助WebApplicationInitializer以Java的方式来配置Delegating-FilterProxy的话,那么所需要做的就是创建一个扩展的新类:
package spittr.config;
import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer;
public class SecurityWebInitializer extends AbstractSecurityWebApplicationInitializer {
}
AbstractSecurityWebApplicationInitializer实现了WebApplication-Initializer,因此Spring会发现它,并用它在Web容器中注册DelegatingFilterProxy。尽管我们可以重载它的appendFilters()或insertFilters()方法来注册自己选择的Filter,但是要注册DelegatingFilterProxy的话,并不需要重载任何方法。
不管通过web.xml还是通过AbstractSecurityWebApplicationInitializer的子类来配置DelegatingFilterProxy,它都会拦截发往应用中的请求,并将请求委托给ID为springSecurityFilterChain bean。
springSecurityFilterChain本身是另一个特殊的Filter,它也被称为FilterChainProxy。它可以链接任意一个或多个其他的Filter。Spring Security依赖一系列Servlet Filter来提供不同的安全特性。但是,几乎不需要知道这些细节,因为不需要显式声明springSecurityFilterChain以及它所链接在一起的其他Filter。当启用Web安全性的时候,会自动创建这些Filter。
编写简单的安全性配置
Spring 3.2引入了新的Java配置方案,完全不再需要通过XML来配置安全性功能了。如下的程序清单展现了Spring Security最简单的Java配置:
package spittr.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
顾名思义,@EnableWebSecurity注解将会启用Web安全功能。但它本身并没有什么用处,Spring Security必须配置在一个实现了WebSecurityConfigurer的bean中,或者(简单起见)扩展WebSecurityConfigurerAdapter。在Spring应用上下文中,任何实现了WebSecurityConfigurer的bean都可以用来配置Spring Security,但是最为简单的方式还是像上述程序那样扩 展WebSecurityConfigurer Adapter类。
@EnableWebSecurity可以启用任意Web应用的安全性功能,不过,如果应用碰巧是使用Spring MVC开发的,那么就应该考虑使用@EnableWebMvcSecurity替代它:
package spittr.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
除了其他的内容以外,@EnableWebMvcSecurity注解还配置了一个Spring MVC参数解析解析器(argument resolver),这样的话处理器方法就能够通过带有@AuthenticationPrincipal注解的参数获得认证用户的principal(或username)。它同时还配置了一个bean,在使用Spring表单绑定标签库来定义表单时,这个bean会自动添加一个隐藏的跨站请求伪造(cross-site request forgery,CSRF)token输入域。
可能希望指定Web安全的细节,这要通过重载WebSecurityConfigurerAdapter中的一个或多个方法来实现。可以通过重载WebSecurityConfigurerAdapter的三个configure()方法来配置Web安全性,这个过程中会使用传递进来的参数设置行为:
| 方 法 | 描 述 |
|---|---|
| configure(WebSecurity) | 通过重载,配置Spring Security的Filter链 |
| configure(HttpSecurity) | 通过重载,配置如何通过拦截器保护请求 |
| configure(AuthenticationManagerBuilder) | 通过重载,配置user-detail服务 |
再看一下上述程序,可以看到它没有重写上述三个configure()方法中的任何一个,这就说明了为什么应用现在是被锁定的。尽管对于需求来讲默认的Filter链是不错的,但是默认的configure(HttpSecurity)实际上等同于如下所示:
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequest()
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic();
}
这个简单的默认配置指定了该如何保护HTTP请求,以及客户端认证用户的方案。通过调用authorizeRequests()和anyRequest().authenticated()就会要求所有进入应用的HTTP请求都要进行认证。它也配置Spring Security支持基于表单的登录以及HTTP Basic方式的认证。
同时,因为没有重载configure(AuthenticationManagerBuilder)方法,所以没有用户存储支撑认证过程。没有用户存储,实际上就等于没有用户。所以,在这里所有的请求都需要认证,但是没有人能够登录成功。
为了让Spring Security满足应用的需求,还需要再添加一点配置:
- 配置用户存储
- 指定哪些请求需要认证,哪些请求不需要认证,以及所需要的权限
- 提供一个自定义的登录页面,替代原来简单的默认登录页
除了Spring Security的这些功能,可能还希望基于安全限制,有选择性地在Web视图上显示特定的内容。
选择查询用户详细信息的服务
Spring Security非常灵活,能够基于各种数据存储来认证用户。它内置了多种常见的用户存储场景,如内存、关系型数据库以及LDAP。但也可以编写并插入自定义的用户存储实现。
使用基于内存的用户存储
因为安全配置类扩展了WebSecurityConfigurerAdapter,因此配置用户存储的最简单方式就是重载configure()方法,并以AuthenticationManagerBuilder作为传入参数。AuthenticationManagerBuilder有多个方法可以用来配置Spring Security对认证的支持。通过inMemoryAuthentication()方法,可以启用、配置并任意填充基于内存的用户存储。
SecurityConfig重载了configure()方法,并使用两个用户来配置内存用户存储:
package spittr.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("user").password("password").roles("USER").and()
.withUser("admin").password("password").roles("USER", "ADMIN");
}
}
configure()方法中的AuthenticationManagerBuilder使用构造者风格的接口来构建认证配置。通过简单地调用inMemoryAuthentication()就能启用内存用户存储。但是还需要有一些用户,否则的话,这和没有用户并没有什么区别。
需要调用withUser()方法为内存用户存储添加新的用户,这个方法的参数是username。withUser()方法返回的是UserDetailsManagerConfigurer.UserDetailsBuilder,这个对象提供了多个进一步配置用户的方法,包括设置用户密码的password()方法以及为给定用户授予一个或多个角色权限的roles()方法。
上述程序添加了两个用户,“user”和“admin”,密码均为“password”。“user”用户具有USER角色,而“admin”用户具有ADMIN和 USER两个角色。可以看到,and()方法能够将多个用户的配置连接起来。
除了password()、roles()和and()方法以外,还有其他的几个方法可以用来配置内存用户存储中的用户信息。下表描述了UserDetailsManagerConfigurer.UserDetailsBuilder对象所有可用的方法
需要注意的是,roles()方法是authorities()方法的简写形式。roles()方法所给定的值都会添加一个“ROLE_”前缀,并将其作为权限授予给用户。实际上,如下的用户配置与上述程序程序等价的:
auth
.inMemoryAuthentication()
.withUser("user").password("password").authorities("ROLE_USER").and()
.withUser("admin").password("password").authorities("ROLE_USER", "ROLE_ADMIN");
| 方 法 | 描 述 |
|---|---|
| accountExpired(boolean) | 定义账号是否已经过期 |
| accountLocked(boolean) | 定义账号是否已经锁定 |
| and() | 用来连接配置 |
| authorities(GrantedAuthority...) | 授予某个用户一项或多项权限 |
| authorities(List<? extends GrantedAuthority>) | 授予某个用户一项或多项权限 |
| authorities(String...) | 授予某个用户一项或多项权限 |
| credentialsExpired(boolean) | 定义凭证是否已经过期 |
| disabled(boolean) | 定义账号是否已被禁用 |
| password(String) | 定义用户的密码 |
| roles(String...) | 授予某个用户一项或多项角色 |
基于数据库表进行认证
用户数据通常会存储在关系型数据库中,并通过JDBC进行访问。为了配置Spring Security使用以JDBC为支撑的用户存储,可以使用jdbcAuthentication()方法,所需的最少配置如下所示:
@Autowired
DataSource dataSource;
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.
jdbcAuthentication()
.dataSource(dataSource);
}
必须要配置的只是一个DataSource,这样的话,就能访问关系型数据库了。在这里,DataSource是通过自动装配的技巧得到的。
重写默认的用户查询功能
尽管默认的最少配置能够让一切运转起来,但是它对我们的数据库模式有一些要求。它预期存在某些存储用户数据的表。更具体来说,下面的代码片段来源于Spring Security内部,这块代码展现了当查找用户信息时所执行的SQL查询语句:
public static final String DEF_USERS_BY_USERNAME_QUERY = "select username,password,enabled from users where username = ?";
public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY = "select username,authority from authorities where username = ?";
public static final String DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY = "select g.id,g.group_name,ga.authority from groups g,group_members gm,group_authorities ga where gm.username = ? and g.id=ga.group_id and g.id = gm.group_id";
在第一个查询中,获取了用户的用户名、密码以及是否启用的信息,这些信息会用来进行用户认证。接下来的查询查找了用户所授予的权限,用来进行鉴权,最后一个查询中,查找了用户作为群组的成员所授予的权限。
如果能够在数据库中定义和填充满足这些查询的表,那么基本上就不需要你再做什么额外的事情了。但是,也有可能数据库与上面所述并不一致,那么就会希望在查询上有更多的控制权。如果是这样的话,可以按照如下的方式配置自己的查询:
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.
jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery("select username,password,true from Spitter where username = ?")
.authoritiesByUsernameQuery("select username,'ROLE_USER' from Spitter where uaername = ?");
}
在本例中,只重写了认证和基本权限的查询语句,但是通过调用group-AuthoritiesByUsername()方法,也能够将群组权限重写为自定义的查询语句。
将默认的SQL查询替换为自定义的设计时,很重要的一点就是要遵循查询的基本协议。所有查询都将用户名作为唯一的参数。认证查询会选取用户名、密码以及启用状态信息。权限查询会选取零行或多行包含该用户名及其权限信息的数据。群组权限查询会选取零行或多行数据,每行数据中都会包含群组ID、群组名称以及权限。
使用转码后的密码
看一下上面的认证查询,它会预期用户密码存储在了数据库之中。这里唯一的问题在于如果密码明文存储的话,会很容易受到黑客的窃取。但是,如果数据库中的密码进行了转码的话,那么认证就会失败,因为它与用户提交的明文密码并不匹配。
为了解决这个问题,需要借助passwordEncoder()方法指定一个密码转码器(encoder):
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.
jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery("select username,password,true from Spitter where username = ?")
.authoritiesByUsernameQuery("select username,'ROLE_USER' from Spitter where uaername = ?")
.passwordEncoder(new StandardPasswordEncoder("53cr3t"));
}
passwordEncoder()方法可以接受Spring Security中PasswordEncoder接口的任意实现。Spring Security的加密模块包括了三个这样的实现:BCryptPasswordEncoder、NoOpPasswordEncoder和StandardPasswordEncoder。
上述的代码中使用了StandardPasswordEncoder,但是如果内置的实现无法满足需求时,你可以提供自定义的实现。PasswordEncoder接口非常简单:
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodePassword);
}
不管使用哪一个密码转码器,都需要理解的一点是,数据库中的密码是永远不会解码的。所采取的策略与之相反,用户在登录时输入的密码会按照相同的算法进行转码,然后再与数据库中已经转码过的密码进行对比。这个对比是在PasswordEncoder的matches()方法中进行的。
基于LDAP进行认证
为了让Spring Security使用基于LDAP的认证,我们可以使用ldapAuthentication()方法。这个方法在功能上类似于jdbcAuthentication(),只不过是LDAP版本。如下的configure()方法展现了LDAP认证的简单配置:
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchFilter("(uid={0})")
.groupSearchFilter("member={0}");
}
方法userSearchFilter()和groupSearchFilter()用来为基础LDAP查询提供过滤条件,它们分别用于搜索用户和组。默认情况下,对于用户和组的基础查询都是空的,也就是表明搜索会在LDAP层级结构的根开始。但是可以通过指定查询基础来改变这个默认行为:
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}");
}
userSearchBase()属性为查找用户提供了基础查询。同样,groupSearchBase()为查找组指定了基础查询。声明用户应该在名为people的组织单元下搜索而不是从根开始。而组应该在名为groups的组织单元下搜索。
配置密码比对
基于LDAP进行认证的默认策略是进行绑定操作,直接通过LDAP服务器认证用户。另一种可选的方式是进行比对操作。这涉及将输入的密码发送到LDAP目录上,并要求服务器将这个密码和用户的密码进行比对。因为比对是在LDAP服务器内完成的,实际的密码能保持私密。
如果希望通过密码比对进行认证,可以通过声明passwordCompare()方法来实现:
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare();
}
默认情况下,在登录表单中提供的密码将会与用户的LDAP条目中的userPassword属性进行比对。如果密码被保存在不同的属性中,可以通过passwordAttribute()方法来声明密码属性的名称:
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare()
.passwordEncoder(new Md5PasswordEncoder())
.passowrdAttribute("passcode");
}
在本例中,指定了要与给定密码进行比对的是“passcode”属性。另外,还可以指定密码转码器。在进行服务器端密码比对时,有一点非常好,那就是实际的密码在服务器端是私密的。但是进行尝试的密码还是需要通过线路传输到LDAP服务器上,这可能会被黑客所拦截。为了避免这一点,可以通过调用passwordEncoder()方法指定加密策略。
在本示例中,密码会进行MD5加密。这需要LDAP服务器上密码也使用MD5进行加密。
引用远程的LDAP服务器
默认情况下,Spring Security的LDAP认证假设LDAP服务器监听本机的33389端口。但是,如果你的LDAP服务器在另一台机器上,那么可以使用contextSource()方法来配置这个地址:
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.contextSource()
.url("ldap://habuma.com:389/dc=habuma,dc=com");
}
contextSource()方法会返回一个ContextSourceBuilder对象,这个对象除了其他功能以外,还提供了url()方法用来指定LDAP服务器的地址。
配置嵌入式的LDAP服务器
如果没有现成的LDAP服务器供认证使用,Spring Security还提供了嵌入式的LDAP服务器。不再需要设置远程LDAP服务器的URL,只需通过root()方法指定嵌入式服务器的根前缀就可以了:
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.contextSource()
.root("dc=habuma,dc=com");
}
当LDAP服务器启动时,它会尝试在类路径下寻找LDIF文件来加载数据。LDIF(LDAP Data Interchange Format,LDAP数据交换格式)是以文本文件展现LDAP数据的标准方式。每条记录可以有一行或多行,每项包含一个名值对。记录之间通过空行进行分割。
如果不想让Spring从整个根路径下搜索LDIF文件的话,那么可以通过调用ldif()方法来明确指定加载哪个LDIF文件:
@override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.contextSource()
.root("dc=habuma,dc=com")
.ldif("classpath:users.ldif");
}
在这里,明确要求LDAP服务器从类路径根目录下的users.ldif文件中加载内容。
配置自定义的用户服务
假设需要认证的用户存储在非关系型数据库中,如Mongo或Neo4j,在这种情况下,需要提供一个自定义的UserDetailsService接口实现。
UserDetailsService接口非常简单:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
需要做的就是实现loadUserByUsername()方法,根据给定的用户名来查找用户。loadUserByUsername()方法会返回代表给定用户的UserDetails对象。如下的程序清单展现了一个UserDetailsService的实现,它会从给定的SpitterRepository实现中查找用户。
paakage spittr.security;
import org.spingframework.security.core.GrantedAuthority;
import org.spingframework.security.core.authority.SimpleGrantedAuthority;
import org.spingframework.security.core.userdetails.User;
import org.spingframework.security.core.userdetails.UserDetails;
import org.spingframework.security.core.userdetails.UserDetailsService;
import org.spingframework.security.core.userdetails.UsernameNotFoundException;
import spittr.Spitter;
import spittr.data.SpitterRepository;
public class SpitterUserService implements UserDetailsService {
private final SpitterRepository spitterRepository;
public SpitterUserService(SpitterRepository spitterRepository) {
this.spitterRepository = spitterRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Spitter spitter spitterRepository.findByUsername(username);
if (spitter != null){
List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new SimpleGrantedAuthority("ROLE_SPITTER"));
return new User(spitter.getUsername(),spitter.getPassword(),authorities);
}
throw new UsernameNotFoundException("User '" + username + "' not found.");
}
}
SpitterUserService有意思的地方在于它并不知道用户数据存储在什么地方。设置进来的SpitterRepository能够从关系型数据库、文档数据库或图数据中查找Spitter对象,甚至可以伪造一个。SpitterUserService不知道也不会关心底层所使用的数据存储。它只是获得Spitter对象,并使用它来创建User对象。(User是UserDetails的具体实现。)
为了使用SpitterUserService来认证用户,可以通过userDetailsService()方法将其设置到安全配置中:
@Autowired
SpitterRepository spitterRepository;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(new SpitterUserService(spitterRepository));
}
userDetailsService()方法(类似于jdbcAuthentication()、ldapAuthentication以及inMemoryAuthentication())会配置一个用户存储。不过,这里所使用的不是Spring所提供的用户存储,而是使用UserDetailsService的实现。
另外一种值得考虑的方案就是修改Spitter,让其实现UserDetails。这样的话,loadUserByUsername()就能直接返回Spitter对象了,而不必再将它的值复制到User对象中。
拦截请求
前面看到一个特别简单的Spring Security配置,在这个默认的配置中,会要求所有请求都要经过认证。有些人可能会说,过多的安全性总比安全性太少要好。但也有一种说法就是要适量地应用安全性。
在任何应用中,并不是所有的请求都需要同等程度地保护。有些请求需要认证,而另一些可能并不需要。有些请求可能只有具备特定权限的用户才能访问,没有这些权限的用户会无法访问。
考虑Spittr应用的请求。首页当然是公开的,不需要进行保护。类似地,因为所有的Spittle都是公开的,所以展现Spittle的页面不需要安全性。但是,创建Spittle的请求只有认证用户才能执行。同样,尽管用户基本信息页面是公开的,不需要认证,但是,如果要处理“/spitters/me”请求,并展现当前用户的基本信息时,那么就需要进行认证,从而确定要展现谁的信息。
对每个请求进行细粒度安全性控制的关键在于重载configure(HttpSecurity)方法。如下的代码片段展现了重载的configure(HttpSecurity)方法,它为不同的URL路径有选择地应用安全性:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequets()
.antMatchers("/spitters/me").authenticated()
.antMatchers(HttpMethod.POST, "/spittles").authenticated()
.anyRequest.permitAll();
}
configure()方法中得到的HttpSecurity对象可以在多个方面配置HTTP的安全性。在这里,首先调用authorizeRequests(),然后调用该方法所返回的对象的方法来配置请求级别的安全性细节。其中,第一次调用antMatchers()指定了对“/spitters/me”路径的请求需要进行认证。第二次调用antMatchers()更为具体,说明对“/spittles”路径的HTTP POST请求必须要经过认证。最后对anyRequests()的调用中,说明其他所有的请求都是允许的,不需要认证和任何的权限。
antMatchers()方法中设定的路径支持Ant风格的通配符。在这里并没有这样使用,但是也可以使用通配符来指定路径,如下所示:
.antMatchers("/spitters/**").authenticated()
也可以在一个对antMatchers()方法的调用中指定多个路径:
.antMatchers("/spitters/**", ".spittles/mine").authenticated()
antMatchers()方法所使用的路径可能会包括Ant风格的通配符,而regexMatchers()方法则能够接受正则表达式来定义请求路径。例如,如下代码片段所使用的正则表达式与“/spitters/**”(Ant风格)功能是相同的:
.regexMatchers("/spitters/.*").authenticated()
除了路径选择,还通过authenticated()和permitAll()来定义该如何保护路径。authenticated()要求在执行该请求时,必须已经登录了应用。如果用户没有认证的话,Spring Security的Filter将会捕获该请求,并将用户重定向到应用的登录页面。同时,permitAll()方法允许请求没有任何的安全限制。
除了authenticated()和permitAll()以外,还有其他的一些方法能够用来定义该如何保护请求。下表描述了所有可用的方案:
| 方 法 | 能够做什么 |
|---|---|
| access(String) | 如果给定的SpEL表达式计算结果为true,就允许访问 |
| anonymous() | 允许匿名用户访问 |
| authenticated() | 允许认证过的用户访问 |
| denyAll() | 无条件拒绝所有访问 |
| fullyAuthenticated() | 如果用户是完整认证的话(不是通过Remember-me功能认证的),就允许访问 |
| hasAnyAuthority(String...) | 如果用户具备给定权限中的某一个的话,就允许访问 |
| hasAnyRole(String...) | 如果用户具备给定角色中的某一个的话,就允许访问 |
| hasAuthority(String) | 如果用户具备给定权限的话,就允许访问 |
| hasIpAddress(String) | 如果请求来自给定IP地址的话,就允许访问 |
| hasRole(String) | 如果用户具备给定角色的话,就允许访问 |
| not() | 对其他访问方法的结果求反 |
| permitAll() | 无条件允许访问 |
| rememberMe() | 如果用户是通过Remember-me功能认证的,就允许访问 |
通过使用上表中的方法,所配置的安全性能够不仅仅限于认证用户。例如,可以修改之前的configure()方法,要求用户不仅需要认证,还要具备ROLE_SPITTER权限:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequets()
.antMatchers("/spitters/me").hasAuthority("ROLE_SPITTER")
.antMatchers(HttpMethod.POST, "/spittles").hasAuthority("ROLE_SPITTER")
.anyRequest.permitAll();
}
作为替代方案,还可以使用hasRole()方法,它会自动使用“ROLE_”前缀:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequets()
.antMatchers("/spitters/me").hasRole("SPITTER")
.antMatchers(HttpMethod.POST, "/spittles").hasRole("SPITTER")
.anyRequest.permitAll();
}
可以将任意数量的antMatchers()、regexMatchers()和anyRequest()连接起来,以满足Web应用安全规则的需要。但是,需要知道,这些规则会按照给定的顺序发挥作用。所以,很重要的一点就是将最为具体的请求路径放在前面,而最不具体的路径(如anyRequest())放在最后面。如果不这样做的话,那不具体的路径配置将会覆盖掉更为具体的路径配置。
使用Spring表达式进行安全保护
上表中的大多数方法都是一维的,也就是说可以使用hasRole()限制某个特定的角色,但是不能在相同的路径上同时通过hasIpAddress()限制特定的IP地址。
借 助access()方法,也可以将SpEL作为声明访问限制的一种方式。例如,如下就是使用SpEL表达式来声明具有“ROLE_SPITTER”角色才能访问“/spitter/me”URL:
.antMatchers("/spitters/me").access("hasRole('ROLE_SPITTER')")
这个对“/spitter/me”的安全限制与开始时的效果是等价的,只不过这里使用了SpEL来描述安全规则。如果当前用户被授予了给定角色的话,那hasRole()表达式的计算结果就为true。
让SpEL更强大的原因在于,hasRole()仅是Spring支持的安全相关表达式中的一种,下表列出了Spring Security支持的所有SpEL表达式。
| 安全表达式 | 计 算 结 果 |
|---|---|
| authentication | 用户的认证对象 |
| denyAll | 结果始终为false |
| hasAnyRole(list of roles) | 如果用户被授予了列表中任意的指定角色,结果为true |
| hasRole(role) | 如果用户被授予了指定的角色,结果为true |
| hasIpAddress(IP Address) | 如果请求来自指定IP的话,结果为true |
| isAnonymous() | 如果当前用户为匿名用户,结果为true |
| isAuthenticated() | 如果当前用户进行了认证的话,结果为true |
| isFullyAuthenticated() | 如果当前用户进行了完整认证的话(不是通过Remember-me功能进行的认证),结果为true |
| isRememberMe() | 如果当前用户是通过Remember-me自动认证的,结果为true |
| permitAll | 结果始终为true |
| principal | 用户的principal对象 |
在掌握了Spring Security的SpEL表达式后,就能够不再局限于基于用户的权限进行访问限制了。例如,如果想限制“/spitter/me” URL的访问,不仅需要ROLE_SPITTER,还需要来自指定的IP地址,那么可以按照如下的方式调用access()方法:
.antMatchers("/spitters/me").access("hasRole('ROLE_SPITTER') and hasIpAddress('192.168.1.2')")
强制通道的安全性
传递到configure()方法中的HttpSecurity对象,除了具有authorizeRequests()方法以外,还有一个requiresChannel()方法,借助这个方法能够为各种URL模式声明所要求的通道。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequets()
.antMatchers("/spitters/me").hasRole("SPITTER")
.antMatchers(HttpMethod.POST, "/spittles").hasRole("SPITTER")
.anyRequest.permitAll()
.and()
.requiresChannel()
.antMatchers("/spitter/form")
.requiresSecure();
}
不论何时,只要是对“/spitter/form”的请求,Spring Security都视为需要安全通道(通过调用requiresChannel()确定的)并自动将请求重定向到HTTPS上。
与之相反,有些页面并不需要通过HTTPS传送。例如,首页不包含任何敏感信息,因此并不需要通过HTTPS传送。我们可以使用requiresInsecure()代替requiresSecure()方法,将首页声明为始终通过HTTP传送:
.antMatchers("/")
.requiresInecure()
如果通过HTTPS发送了对“/”的请求,Spring Security将会把请求重定向到不安全的HTTP通道上。
在强制要求通道时,路径的选取方案与authorizeRequests()是相同的。在上述程序中,使用了antMatches(),但我们也可以使用regexMatchers()方法,通过正则表达式选取路径模式。
防止跨站请求伪造
<form method="POST" action="http://www.spittr.com/spittles">
<input type="hidden" name="message" value="I'm stupid" />
<input type="submit" value="Click here to win a new car" />
</form>
假设你禁不住获得一辆新汽车的诱惑,点击了按钮——那么你将会提交表单到如下地址www.spittr.com/spittles。如果…
这是跨站请求伪造(cross-site request forgery,CSRF)的一个简单样例。简单来讲,如果一个站点欺骗用户提交请求到其他服务器的话,就会发生CSRF攻击,这可能会带来消极的后果。尽管提交“I’m stupid!”这样的信息到微博站点算不上什么CSRF攻击的最糟糕场景,但是你可以很容易想到更为严重的攻击情景,它可能会对你的银行账号执行难以预期的操作。
从Spring Security 3.2开始,默认就会启用CSRF防护。实际上,除非你采取行为处理CSRF防护或者将这个功能禁用,否则的话,在应用中提交表单时,你可能会遇到问题。
Spring Security通过一个同步token的方式来实现CSRF防护的功能。它将会拦截状态变化的请求(例如,非GET、HEAD、OPTIONS和TRACE的请求)并检查CSRF token。如果请求中不包含CSRF token的话,或者token不能与服务器端的token相匹配,请求将会失败,并抛出CsrfException异常。
这意味着在你的应用中,所有的表单必须在一个“_csrf”域中提交token,而且这个token必须要与服务器端计算并存储的token一致,这样的话当表单提交的时候,才能进行匹配。
好消息是,Spring Security已经简化了将token放到请求的属性中这一任务。如果你使用Thymeleaf作为页面模板的话,只要
标签的action属性添加了Thymeleaf命名空间前缀,那么就会自动生成一个“_csrf”隐藏域:<form method="POST" th:action="@{/spittles}">
...
</form>
如果使用JSP作为页面模板的话,要做的事情非常类似:
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
更好的功能是,如果使用Spring的表单绑定标签的话,<sf:form >标签会自动为我们添加隐藏的CSRF token标签。
处理CSRF的另外一种方式就是根本不去处理它。我们可以在配置中通过调用csrf().disable()禁用Spring Security的CSRF防护功能,如下所示:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.csrf()
.disable();
}
需要提醒的是,禁用CSRF防护功能通常来讲并不是一个好主意。如果这样做的话,那么应用就会面临CSRF攻击的风险。只有在深思熟虑之后,才能配置。
认证用户
如果我们访问应用的“/login”链接或者导航到需要认证的页面,那么将会在浏览器中展现登录页面。如下所示,在审美上它没有什么令人兴奋的,但是它却能实现所需的功能。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.and()
.authorizeRequets()
.antMatchers("/spitters/me").hasRole("SPITTER")
.antMatchers(HttpMethod.POST, "/spittles").hasRole("SPITTER")
.anyRequest.permitAll()
.and()
.requiresChannel()
.antMatchers("/spitter/form")
.requiresSecure();
}
添加自定义的登录页
创建自定义登录页的第一步就是了解登录表单中都需要些什么。只需看一下默认登录页面的HTML源码,我们就能了解需要些什么:
<html>
<head><title>Login Page</title></head>
<body onload='document.f.username.focus();'>
<h3>Login with Username and Password</h3>
<form name='f' action='/spittr/login' method='POST'>
<table>
<tr><td>User:</td><td>
<input type='text' name='username' value='' /></td></tr>
<tr><td>Password:</td><td>
<input type='password' name='password' /></td></tr>
<tr><td colspan='2'>
<input name='submit' type='submit' value="Login" /></td></tr>
<input name="-csrf" type="hidden" value="6829b1ae-0a14-4920-aac4-5abbd7eeb9ee" />
</table>
</form>
</body>
</html>
需要注意的一个关键点是提交到了什么地方。同时还需要注意username和password输入域,在你的登录页中,需要同样的输入域。最后,假设没有禁用CSRF的话,还需要保证包含了值为CSRF token的“_csrf”输入域。
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<title>Spitter</title>
<link rel="stylesheet"
type="text/css"
th:href="@{/resources/style.css}"></link>
</head>
<body onload='document.f.username.focus();'>
<div id="header" th:include="page :: header"></div>
<div id="content">
<form name='f' th:action='@{/login}' method='POST'>
<table>
<tr><td>User:</td><td>
<input type='text' name='username' value='' /></td></tr>
<tr><td>Password:</td>
<td><input type='password' name='password'/></td></tr>
<tr><td colspan='2'>
<input name="submit" type="submit" value="Login"/></td></tr>
</table>
</form>
</div>
<div id="footer" th:include="page :: copy"></div>
</body>
</html>
需要注意的是,在Thymeleaf模板中,包含了username和password输入域,就像默认的登录页一样,它也提交到了相对于上下文的“/login”页面 上。因为这是一个Thymeleaf模板,因此隐藏的“_csrf”域将会自动添加到表单中。
启用HTTP Basic认证
对于应用程序的人类用户来说,基于表单的认证是比较理想的。当应用程序的使用者是另外一个应用程序的话,使用表单来提示登录的方式就不太适合了。
HTTP Basic认证(HTTP Basic Authentication)会直接通过HTTP请求本身,对要访问应用程序的用户进行认证。你可能在以前见过HTTP Basic认证。当在Web浏览器中使用时,它将向用户弹出一个简单的模态对话框。
但这只是Web浏览器的显示方式。本质上,这是一个HTTP 401响应,表明必须要在请求中包含一个用户名和密码。在REST客户端向它使用的服务进行认证的场景中,这种方式比较适合。
如果要启用HTTP Basic认证的话,只需在configure()方法所传入的HttpSecurity对象上调用httpBasic()即可。另外,还可以通过调用realmName()方法指定域。如下是在Spring Security中启用HTTP Basic认证的典型配置:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login")
.and()
.httpBasic()
.realmName("spittr")
.and()
...
}
和前面一样,在configure()方法中,通过调用add()方法来将不同的配置指令连接在一起。
在httpBasic()方法中,并没有太多的可配置项,甚至不需要什么额外配置。HTTP Basic认证要么开启要么关闭。
启用Remember-me功能
对于应用程序来讲,能够对用户进行认证是非常重要的。但是站在用户的角度来讲,如果应用程序不用每次都提示他们登录是更好的。这就是为什么许多站点提供了Remember-me功能,你只要登录过一次,应用就会记住你,当再次回到应用的时候你就不需要登录了。
Spring Security使得为应用添加Remember-me功能变得非常容易。为了启用这项功能,只需在configure()方法所传入的HttpSecurity对象上调用rememberMe()即可。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login")
.and()
.rememberMe()
.tokenValiditySeconds(2419200)
.key("spittrKey")
...
}
在这里,我们通过一点特殊的配置就可以启用Remember-me功能。默认情况下,这个功能是通过在cookie中存储一个token完成的,这个token最多两周内有效。但是,在这里,我们指定这个token最多四周内有效(2,419,200秒)。
存储在cookie中的token包含用户名、密码、过期时间和一个私钥——在写入cookie前都进行了MD5哈希。默认情况下,私钥的名为SpringSecured,但在这里我们将其设置为spitterKey,使它专门用于Spittr应用。
如此简单。既然Remember-me功能已经启用,我们需要有一种方式来让用户表明他们希望应用程序能够记住他们。为了实现这一点,登录请求必须包含一个名为remember-me的参数。在登录表单中,增加一个简单复选框就可以完成这件事情:
<input id="remember_me" name="remember-me" type="checkbox"/>
<label for="remember_me" class="inline">Remember me</label>
在应用中,与登录同等重要的功能就是退出。如果启用Remember-me功能的话,更是如此,否则的话,用户将永远登录在这个系统中。
退出
其实,按照配置,退出功能已经启用了,不需要再做其他的配置了。需要的只是一个使用该功能的链接。
退出功能是通过Servlet容器中的Filter实现的(默认情况下),这个Filter会拦截针对“/logout”的请求。因此,为应用添加退出功能只需添加如下的链接即可(如下以Thymeleaf代码片段的形式进行了展现):
<a th:href="@{/logout}">Logout</a>
当用户点击这个链接的时候,会发起对“/logout”的请求,这个请求会被Spring Security的LogoutFilter所处理。用户会退出应用,所有的Remember-me token都会被清除掉。在退出完成后,用户浏览器将会重定向到“/login?logout”,从而允许用户进行再次登录。
如果你希望用户被重定向到其他的页面,如应用的首页,那么可以在configure()中进行如下的配置:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login")
.and()
.logout()
.logoutSuccessUrl("/")
...
}
在这里,和前面一样,通过add()连接起了对logout()的调用。logout()提供了配置退出行为的方法。在本例中,调用logoutSuccessUrl()表明在退出成功之后,浏览器需要重定向到“/”。
除了logoutSuccessUrl()方法以外,你可能还希望重写默认的LogoutFilter拦截路径。我们可以通过调用logoutUrl()方法实现这一功能:
.logout()
.logoutSuccessUrl("/")
.logoutUrl("/gignout")
保护视图
使用Spring Security的JSP标签库
Spring Security的JSP标签库很小,只包含三个标签:
| JSP标签 | 作 用 |
|---|---|
| <security:accesscontrollist > | 如果用户通过访问控制列表授予了指定的权限,那么渲染该标签体中的内容 |
| <security:authentication > | 渲染当前用户认证对象的详细信息 |
| <security:authorize > | 如果用户被授予了特定的权限或者SpEL表达式的计算结果为true,那么渲染该标签体中的内容 |
为了使用JSP标签库,需要在对应的JSP中声明它:
<%@ taglib prefix="security" uri="http://www.springframework.org/security/tags" %>
只要标签库在JSP文件中进行了声明,就可以使用它了。看Spring Security提供的这三个标签是如何工作的。
访问认证信息的细节
借助Spring Security JSP标签库,所能做到的最简单的一件事情就是便利地访问用户的认证信息。例如,对于Web站点来讲,在页面顶部以用户名标示显示“欢迎”或“您好”信息是很常见的。这恰恰是<security:authentication >能为我们所做的事情。例如:
Hellp <security:authenticationvproperty="principal.username" />
其中,property用来标示用户认证对象的一个属性。可用的属性取决于用户认证的方式。但是,可以依赖几个通用的属性,在不同的认证方式下,它们都是可用的,如下表所示:
| 认 证 属 性 | 描 述 |
|---|---|
| authorities | 一组用于表示用户所授予权限的GrantedAuthority对象 |
| Credentials | 用于核实用户的凭证(通常,这会是用户的密码) |
| details | 认证的附加信息(IP地址、证件序列号、会话ID等) |
| principal | 用户的基本信息对象 |
在示例中,实际上渲染的是principal属性中嵌套的username属性。
当像前面示例那样使用时,<security:authentication >将在视图中渲染属性的值。但是如果你愿意将其赋值给一个变量,那只需要在var属性中指明变量的名字即可。例如,如下展现了如何将其设置给名为loginId的属性:
<security:authentication property="principal.username" var="loginId" />
这个变量默认是定义在页面作用域内的。但是如果你愿意在其他作用域内创建它,例如请求或会话作用域(或者是能够在javax.servlet.jsp.PageContext中获取的其他作用域),那么可以通过scope属性来声明。例如,要在请求作用域内创建这个变量,那可以使用<security:authentication >按照如下的方式来设置:
<security:authentication property="principal.username" var="loginId" scope="request" />
条件性的渲染内容
有时候视图上的一部分内容需要根据用户被授予了什么权限来确定是否渲染。对于已经登录的用户显示登录表单,或者对还未登录的用户显示个性化的问候信息都是毫无意义的。
Spring Security的<security:authorize >JSP标签能够根据用户被授予的权限有条件地渲染页面的部分内容。例如,在Spittr应用中,对于没有ROLE_SPITTER角色的用户,不会为其显示添加新Spitter记录的表单。
<security:authorize access="hasRole('ROLE_SPITTER')">
<s:url value="/spttles" var="spittle_url" />
<sf:form modelAttribute="spittle" action="${spittle_url}">
<sf:label path="text"><s:message code="label.spittle" text="Enter spittle:"/></sf:label>
<sf:textarea path="text" rows="2" cols="40" />
<sf:errors path="text" />
<br/>
<div class="spitItSubmitIt">
<input type="submit" value="Spit it!" class="status-btn round-btn disabled" />
</div>
</sf:form>
</security:authorize>
access属性被赋值为一个SpEL表达式,这个表达式的值将确定<security:authorize >标签主体内的内容是否渲染。这里使用了hasRole('ROLE_SPITTER')表达式来确保用户具有ROLE_SPITTER角色。但是,当设置access属性时,可以任意发挥SpEL的强大 威力.
借助于这些可用的表达式,可以构造出非常有意思的安全性约束。例如,假设应用中有一些管理功能只能对用户名为habuma的用户可用。也许会像这样使用isAuthenticated()和principal表达式:
<security:authorize access="isAuthenticated() and principal.username=='habuma'">
<a href="/admin">Administration</a>
</security:authorize>
在安全配置中,添加一个对antMatchers()方法的调用将会严格限制对“/admin”这 个URL的访问。
.antMatchers("/admin")
.access("isAuthenticated() and principal.username=='habuma'")
现在,管理功能已经被锁定了。URL地址得到了保护,并且到这个URL的链接在用户没有授权使用的情况下不会显示。但是为了做到这一点,需要在两个地方声明SpEL表达式——在安全配置中以及在<security:authorize >标签的access属性中。
这是<security:authorize >的url属性所要做的事情。它不像access属性那样明确声明安全性限制,url属性对一个给定的URL模式会间接引用其安全性约束。鉴于已经在Spring Security配置中为“/admin”声明了安全性约束,所以我们可以这样使用url属性:
<security:authorize url="/admin">
<spring:url value="/admin" var="admin_url"/>
<br/>
<a href="/admin">Administration</a>
</security:authorize>
因为只有基本信息中用户名为“habuma”的已认证用户才能访问“/admin” URL,所以只有满足以上条件,<security:authorize >标签主体中的内容才会被渲染。这样只在一个地方配置了表达式(安全配置中),但是在两个地方进行了应用。
使用Thymeleaf的Spring Security方言
Thymeleaf的安全方言提供了与Spring Security标签库相对应的属性:
| 属 性 | 作 用 |
|---|---|
| sec:authentication | 渲染认证对象的属性。类似于Spring Security的<sec:authentication />JSP标签 |
| sec:authorize | 基于表达式的计算结果,条件性的渲染内容。类似于Spring Security的<sec:authorize />JSP标签 |
| sec:authorize-acl | 基于表达式的计算结果,条件性的渲染内容。类似于Spring Security的<security:accesscontrollist > JSP标签 |
| sec:authorize-expr | sec:authorize属性的别名 |
| sec:authorize-url | 基于给定URL路径相关的安全规则,条件性的渲染内容。类似于Spring Security的基于表达式的计算结果,条件性的渲染内容。类似于Spring Security的<sec:authorize /> JSP标签使用url属性时的场景 |
为了使用安全方言,我们需要确保Thymeleaf Extras Spring Security已经位于应用的类路径下。然后,还需要在配置中使用SpringTemplateEngine来注册SpringSecurity Dialect。
@Bean
public SpringTemplateEngine templateEngine(TemplateResolver templateResolver) {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver);
templateEngine.addDialect(new SpringSecurityDialect());
return templateEngine;
}
安全方言注册完成之后,就可以在Thymeleaf模板中使用它的属性了。首先,需要在使用这些属性的模板中声明安全命名空间:
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
...
</html>
在这里,标准的Thymeleaf方法依旧与之前一样,使用th前缀,安全方言则设置为使用sec前缀。
这样就能在任意合适的地方使用Thymeleaf属性了。比如,假设想要为认证用户渲染“Hello”文本。如下的Thymeleaf模板代码片段就能完成这项任务:
<div sec:authorize="isAuthenticated()">
Hello there...<span sec:authentication="name">someone</span>
</div>
sec:authorize属性会接受一个SpEL表达式。如果表达式的计算结果为true,那么元素的主体内容就会渲染。在本例中,表达式为isAuthenticated(),所以只有用户已经进行了认证,才会渲染
在Spring Security中,借助<sec:authorize >JSP标签的url属性能够基于给定URL的权限有条件地渲染内容。在Thymeleaf 中,可以通过sec:authorize-url属性完成相同的功能。例如,如下Thymeleaf代码片段所实现的功能与之前<sec:authorize > JSP标签和url属性所实现的功能是相同的:
<span sec:authorize-url="admin">
<br/><a th:href="@{/admin}">Admin</a>
</span>
如果用户有权限访问“/admin”的话,那么到管理页面的链接就会渲染,否则的话,这个链接将不会渲染。