第 1 章 一个简单的HelloWorld Spring Security中可以使用Acegi-1.x时代的普通配置方式,也可以使用从2.0时代才出现的命名空间配置方式,实际上这两者实现的功能是完全一致的,只是新的命名空间配置方式可以把原来需要几百行的配置压缩成短短的几十行。我们的教程中都会使用命名空间的方式进行配置,凡事务求最简。 1.1. 配置过滤器 为了在项目中使用Spring Security控制权限,首先要在web.xml中配置过滤器,这样我们就可以控制对这个项目的每个请求了。 springSecurityFilterChain org.springframework.web.filter.DelegatingFilterProxy springSecurityFilterChain /* 所有的用户在访问项目之前,都要先通过Spring Security的检测,这从第一时间把没有授权的请求排除在系统之外,保证系统资源的安全。关于过滤器配置的更多讲解可以参考http://www.family168.com/tutorial/jsp/html/jsp-ch-07.html#jsp-ch-07-03-01。 1.2. 使用命名空间 在applicationContext.xml中使用Spring Security提供的命名空间进行配置。 声明在xml中使用Spring Security提供的命名空间。 http部分配置如何拦截用户请求。auto-config='true'将自动配置几种常用的权限控制机制,包括form, anonymous, rememberMe。 我们利用intercept-url来判断用户需要具有何种权限才能访问对应的url资源,可以在pattern中指定一个特定的url资源,也可以使用通配符指定一组类似的url资源。例子中定义的两个intercepter-url,第一个用来控制对/admin.jsp的访问,第二个使用了通配符/**,说明它将控制对系统中所有url资源的访问。 在实际使用中,Spring Security采用的是一种就近原则,就是说当用户访问的url资源满足多个intercepter-url时,系统将使用第一个符合条件的intercept-url进行权限控制。在我们这个例子中就是,当用户访问/admin.jsp时,虽然两个intercept-url都满足要求,但因为第一个intercept-url排在上面,所以Spring Security会使用第一个intercept-url中的配置处理对/admin.jsp的请求,也就是说,只有那些拥有了ROLE_ADMIN权限的用户才能访问/admin.jsp。 access指定的权限部分比较有趣,大家可以注意到这些权限标示符都是以ROLE_开头的,实际上这与Spring Security中的Voter机制有着千丝万缕的联系,只有包含了特定前缀的字符串才会被Spring Security处理。目前来说我们只需要记住这一点就可以了,在教程以后的部分中我们会详细讲解Voter的内容。 user-service中定义了两个用户,admin和user。为了简便起见,我们使用明文定义了两个用户对应的密码,这只是为了当前演示的方便,之后的例子中我们会使用Spring Security提供的加密方式,避免用户密码被他人窃取。 最最重要的部分是authorities,这里定义了这个用户登陆之后将会拥有的权限,它与上面intercept-url中定义的权限内容一一对应。每个用户可以同时拥有多个权限,例子中的admin用户就拥有ROLE_ADMIN和ROLE_USER两种权限,这使得admin用户在登陆之后可以访问ROLE_ADMIN和ROLE_USER允许访问的所有资源。 与之对应的是,user用户就只拥有ROLE_USER权限,所以他只能访问ROLE_USER允许访问的资源,而不能访问ROLE_ADMIN允许访问的资源。 1.3. 完善整个项目 因为Spring Security是建立在Spring的基础之上的,所以web.xml中除了需要配置我们刚刚提到的过滤器,还要加上加载Spring的相关配置。最终得到的web.xml看起来像是这样: contextConfigLocation classpath:applicationContext*.xml springSecurityFilterChain org.springframework.web.filter.DelegatingFilterProxy springSecurityFilterChain /* org.springframework.web.context.ContextLoaderListener 演示不同权限的用户登陆之后可以访问不同的资源,我们为项目添加了两个jsp文件,admin.jsp和index.jsp。其中admin.jsp只有那些拥有ROLE_ADMIN权限的用户才能访问,而index.jsp只允许那些拥有ROLE_USER权限的用户才能访问。 最终我们的整个项目会变成下面这样: + ch001/ + src/ + main/ + resources/ * applicationContext.xml + webapp/ + WEB-INF/ * web.xml * admin.jsp * index.jsp + test/ + resources/ * pom.xml 1.4. 运行示例 首先确保自己安装了Maven2。如果之前没用过Maven2,可以参考我们的Maven2教程http://www.family168.com/oa/maven2/html/index.html。 安装好Maven2之后,进入ch001目录,然后执行mvn。 信息: Root WebApplicationContext: initialization completed in 1578 ms 2009-05-28 11:37:50.171::INFO: Started SelectChannelConnector@0.0.0.0:8080 [INFO] Started Jetty Server [INFO] Starting scanner at interval of 10 seconds. 等到项目启动完成后。打开浏览器访问http://localhost:8080/ch001/就可以看到登陆页面。 图 1.1. 用户登陆 这个简陋的页面是Spring Security自动生成的,一来为了演示的方便,二来避免用户自己编写登陆页面时犯错,Spring Security为了避免可能出现的风险,连测试用的登录页面都自动生成出来了。在这里我们就省去编写登陆页面的步骤,直接使用默认生成的登录页面进行演示吧。 首先让我们输入一个错误用的用户名或密码,这里我们使用test/test,当然这个用户是不存在的,点击提交之后我们会得到这样一个登陆错误提示页面。 图 1.2. 登陆失败 如果输入的是正确的用户名和密码,比如user/user,系统在登陆成功后会默认跳转到index.jsp。 图 1.3. 登陆成功 这时我们可以点击admin.jsp链接访问admin.jsp,也可以点击logout进行注销。 如果点击了logout,系统会注销当前登陆的用户,然后跳转至登陆页面。如果点击了admin.jsp链接就会显示如下页面。 图 1.4. 拒绝访问 很遗憾,user用户是无法访问/admin.jsp这个url资源的,这在上面的配置文件中已经有过深入的讨论。我们在这里再简要重复一遍:user用户拥有ROLE_USER权限,但是/admin.jsp资源需要用户拥有ROLE_ADMIN权限才能访问,所以当user用户视图访问被保护的/admin.jsp时,Spring Security会在中途拦截这一请求,返回拒绝访问页面。 为了正常访问admin.jsp,我们需要先点击logout注销当前用户,然后使用admin/admin登陆系统,然后再次点击admin.jsp链接就会显示出admin.jsp中的内容。 图 1.5. 显示admin.jsp 根据我们之前的配置,admin用户拥有ROLE_ADMIN和ROLE_USER两个权限,因为他拥有ROLE_USER权限,所以可以访问/index.jsp,因为他拥有ROLE_ADMIN权限,所以他可以访问/admin.jsp。 至此,我们很高兴的宣布,咱们已经正式完成,并运行演示了一个最简单的由Spring Security保护的web系统,下一步我们会深入讨论Spring Security为我们提供的其他保护功能,多姿多彩的特性。 第 2 章 使用数据库管理用户权限 上一章节中,我们把用户信息和权限信息放到了xml文件中,这是为了演示如何使用最小的配置就可以使用Spring Security,而实际开发中,用户信息和权限信息通常是被保存在数据库中的,为此Spring Security提供了通过数据库获得用户权限信息的方式。 2.1. 修改配置文件 为了从数据库中获取用户权限信息,我们所需要的仅仅是修改配置文件中的authentication-provider部分。 将上一章配置文件中的user-service替换为jdbc-user-service,替换内容如下所示: 将上述红色部分替换为下面黄色部分。 现在只要再为jdbc-user-service提供一个dataSource就可以让Spring Security使用数据库中的权限信息了。在此我们使用spring创建一个演示用的dataSource实现,这个dataSource会连接到hsqldb数据库,从中获取用户权限信息。[1] 最终的配置文件如下所示: 2.2. 数据库表结构 Spring Security默认情况下需要两张表,用户表和权限表。以下是hsqldb中的建表语句: create table users( username varchar_ignorecase(50) not null primary key, password varchar_ignorecase(50) not null, enabled boolean not null ); create table authorities ( username varchar_ignorecase(50) not null, authority varchar_ignorecase(50) not null, constraint fk_authorities_users foreign key(username) references users(username) ); create unique index ix_auth_username on authorities (username,authority); users:用户表。包含username用户登录名,password登陆密码,enabled用户是否被禁用三个字段。 其中username用户登录名为主键。 authorities:权限表。包含username用户登录名,authorities对应权限两个字段。 其中username字段与users用户表的主键使用外键关联。 对authorities权限表的username和authority创建唯一索引,提高查询效率。 Spring Security会在初始化时,从这两张表中获得用户信息和对应权限,将这些信息保存到缓存中。其中users表中的登录名和密码用来控制用户的登录,而权限表中的信息用来控制用户登陆后是否有权限访问受保护的系统资源。 我们在示例中预先初始化了一部分数据: insert into users(username,password,enabled) values('admin','admin',true); insert into users(username,password,enabled) values('user','user',true); insert into authorities(username,authority) values('admin','ROLE_ADMIN'); insert into authorities(username,authority) values('admin','ROLE_USER'); insert into authorities(username,authority) values('user','ROLE_USER'); 上述sql中,我们创建了两个用户admin和user,其中admin拥有ROLE_ADMIN和ROLE_USER权限,而user只拥有ROLE_USER权限。这和我们上一章中的配置相同,因此本章实例的效果也和上一章完全相同,这里就不再赘述了。 实例见ch002。 [1] javax.sql.DataSource是一个用来定义数据库连接池的统一接口。当我们想调用任何实现了javax.sql.DataSource接口的连接池,只需要调用接口提供的getConnection()就可以获得连接池中的jdbc连接。javax.sql.DataSource可以屏蔽连接池的不同实现,我们使用的连接池即可能由第三方包单独提供,也可能是由j2ee容器统一管理提供的。 第 3 章 自定义数据库表结构 Spring Security默认提供的表结构太过简单了,其实就算默认提供的表结构很复杂,也无法满足所有企业内部对用户信息和权限信息管理的要求。基本上每个企业内部都有一套自己的用户信息管理结构,同时也会有一套对应的权限信息体系,如何让Spring Security在这些已有的数据结构之上运行呢? 3.1. 自定义表结构 假设我们实际使用的表结构如下所示: -- 角色 create table role( id bigint, name varchar(50), descn varchar(200) ); alter table role add constraint pk_role primary key(id); alter table role alter column id bigint generated by default as identity(start with 1); -- 用户 create table user( id bigint, username varchar(50), password varchar(50), status integer, descn varchar(200) ); alter table user add constraint pk_user primary key(id); alter table user alter column id bigint generated by default as identity(start with 1); -- 用户角色连接表 create table user_role( user_id bigint, role_id bigint ); alter table user_role add constraint pk_user_role primary key(user_id, role_id); alter table user_role add constraint fk_user_role_user foreign key(user_id) references user(id); alter table user_role add constraint fk_user_role_role foreign key(role_id) references role(id); 上述共有三张表,其中user用户表,role角色表为保存用户权限数据的主表,user_role为关联表。user用户表,role角色表之间为多对多关系,就是说一个用户可以有多个角色。ER图如下所示: 图 3.1. 数据库表关系 3.2. 初始化数据 创建两个用户,admin和user。admin用户拥有“管理员”角色,user用户拥有“用户”角色。 insert into user(id,username,password,status,descn) values(1,'admin','admin',1,'管理员'); insert into user(id,username,password,status,descn) values(2,'user','user',1,'用户'); insert into role(id,name,descn) values(1,'ROLE_ADMIN','管理员角色'); insert into role(id,name,descn) values(2,'ROLE_USER','用户角色'); insert into user_role(user_id,role_id) values(1,1); insert into user_role(user_id,role_id) values(1,2); insert into user_role(user_id,role_id) values(2,2); 3.3. 获得自定义用户权限信息 现在我们要在这样的数据结构基础上使用Spring Security,Spring Security所需要的数据只是为了处理两种情况,一是判断登录用户是否合法,二是判断登陆的用户是否有权限访问受保护的系统资源。 我们所要做的工作就是在现有数据结构的基础上,为Spring Security提供这两种数据。 3.3.1. 处理用户登陆 当用户登陆时,系统需要判断用户登录名是否存在,登陆密码是否正确,当前用户是否被禁用。我们使用下列SQL来提取这三个信息。 select username,password,status as enabled from user where username=? 3.3.2. 检验用户权限 用户登陆之后,系统需要获得该用户的所有权限,根据用户已被赋予的权限来判断哪些系统资源可以被用户访问,哪些资源不允许用户访问。 以下SQL就可以获得当前用户所拥有的权限。 select u.username,r.name as authority from user u join user_role ur on u.id=ur.user_id join role r on r.id=ur.role_id where u.username=?"/> 将这两条SQL语句配置到xml中,就可以让Spring Security从我们自定义的表结构中提取数据了。最终配置文件如下所示: users-by-username-query为根据用户名查找用户,系统通过传入的用户名查询当前用户的登录名,密码和是否被禁用这一状态。 authorities-by-username-query为根据用户名查找权限,系统通过传入的用户名查询当前用户已被授予的所有权限。 实例见ch003。 第 4 章 自定义登陆页面 Spring Security虽然默认提供了一个登陆页面,但是这个页面实在太简陋了,只有在快速演示时才有可能它做系统的登陆页面,实际开发时无论是从美观还是实用性角度考虑,我们都必须实现自定义的登录页面。 4.1. 实现自定义登陆页面 自己实现一个login.jsp,放在src/main/webapp/目录下。 + ch004/ + src/ + main/ + resources/ * applicationContext.xml + webapp/ + WEB-INF/ * web.xml * admin.jsp * index.jsp * login.jsp + test/ + resources/ * pom.xml 4.2. 修改配置文件 在xml中的http标签中添加一个form-login标签。 让没登陆的用户也可以访问login.jsp。[2] 这是因为配置文件中的“/**”配置,要求用户访问任意一个系统资源时,必须拥有ROLE_USER角色,/login.jsp也不例外,如果我们不为/login.jsp单独配置访问权限,会造成用户连登陆的权限都没有,这是不正确的。 login-page表示用户登陆时显示我们自定义的login.jsp。 这时我们访问系统显示的登陆页面将是我们上面创建的login.jsp。 authentication-failure-url表示用户登陆失败时,跳转到哪个页面。 当用户输入的登录名和密码不正确时,系统将再次跳转到/login.jsp,并添加一个error=true参数作为登陆失败的标示。 default-target-url表示登陆成功时,跳转到哪个页面。[3] 4.3. 登陆页面中的参数配置 以下是我们创建的login.jsp页面的主要代码。 Spring Security所做的就是在系统初始化时,将以上XML中的信息转换为特定的数据格式,而框架中其他组件可以利用这些特定格式的数据,用于控制之后的验证操作。 现在这些资源信息都保存在数据库中,我们可以使用上面介绍的SQL语句从数据中查询。 select re.res_string,r.name from role r join resc_role rr on r.id=rr.role_id join resc re on re.id=rr.resc_id order by re.priority 下面要开始编写实现代码了。 1. 搜索数据库获得资源信息。 我们通过定义一个MappingSqlQuery实现数据库操作。 private class ResourceMapping extends MappingSqlQuery { protected ResourceMapping(DataSource dataSource, String resourceQuery) { super(dataSource, resourceQuery); compile(); } protected Object mapRow(ResultSet rs, int rownum) throws SQLException { String url = rs.getString(1); String role = rs.getString(2); Resource resource = new Resource(url, role); return resource; } } 这样我们可以执行它的execute()方法获得所有资源信息。 protected Map findResources() { ResourceMapping resourceMapping = new ResourceMapping(getDataSource(), resourceQuery); Map resourceMap = new LinkedHashMap(); for (Resource resource : (List) resourceMapping.execute()) { String url = resource.getUrl(); String role = resource.getRole(); if (resourceMap.containsKey(url)) { String value = resourceMap.get(url); resourceMap.put(url, value + "," + role); } else { resourceMap.put(url, role); } } return resourceMap; } 2. 使用获得的资源信息组装requestMap。 3. protected LinkedHashMap buildRequestMap() { 4. LinkedHashMap requestMap = null; 5. requestMap = new LinkedHashMap(); 6. 7. ConfigAttributeEditor editor = new ConfigAttributeEditor(); 8. 9. Map resourceMap = this.findResources(); 10. 11. for (Map.Entry entry : resourceMap.entrySet()) { 12. RequestKey key = new RequestKey(entry.getKey(), null); 13. editor.setAsText(entry.getValue()); 14. requestMap.put(key, 15. (ConfigAttributeDefinition) editor.getValue()); 16. } 17. 18. return requestMap; 19. } 20. 使用urlMatcher和requestMap创建DefaultFilterInvocationDefinitionSource。 21. public Object getObject() { 22. return new DefaultFilterInvocationDefinitionSource(this 23. .getUrlMatcher(), this.buildRequestMap()); 24. } 这样我们就获得了DefaultFilterInvocationDefinitionSource,剩下的只差把这个我们自己创建的类替换掉原有的代码了。 完整代码如下所示: package com.family168.springsecuritybook.ch005; import java.sql.ResultSet; import java.sql.SQLException; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import javax.sql.DataSource; import org.springframework.beans.factory.FactoryBean; import org.springframework.jdbc.core.support.JdbcDaoSupport; import org.springframework.jdbc.object.MappingSqlQuery; import org.springframework.security.ConfigAttributeDefinition; import org.springframework.security.ConfigAttributeEditor; import org.springframework.security.intercept.web.DefaultFilterInvocationDefinitionSource; import org.springframework.security.intercept.web.FilterInvocationDefinitionSource; import org.springframework.security.intercept.web.RequestKey; import org.springframework.security.util.AntUrlPathMatcher; import org.springframework.security.util.UrlMatcher; public class JdbcFilterInvocationDefinitionSourceFactoryBean extends JdbcDaoSupport implements FactoryBean { private String resourceQuery; public boolean isSingleton() { return true; } public Class getObjectType() { return FilterInvocationDefinitionSource.class; } public Object getObject() { return new DefaultFilterInvocationDefinitionSource(this .getUrlMatcher(), this.buildRequestMap()); } protected Map findResources() { ResourceMapping resourceMapping = new ResourceMapping(getDataSource(), resourceQuery); Map resourceMap = new LinkedHashMap(); for (Resource resource : (List) resourceMapping.execute()) { String url = resource.getUrl(); String role = resource.getRole(); if (resourceMap.containsKey(url)) { String value = resourceMap.get(url); resourceMap.put(url, value + "," + role); } else { resourceMap.put(url, role); } } return resourceMap; } protected LinkedHashMap buildRequestMap() { LinkedHashMap requestMap = null; requestMap = new LinkedHashMap(); ConfigAttributeEditor editor = new ConfigAttributeEditor(); Map resourceMap = this.findResources(); for (Map.Entry entry : resourceMap.entrySet()) { RequestKey key = new RequestKey(entry.getKey(), null); editor.setAsText(entry.getValue()); requestMap.put(key, (ConfigAttributeDefinition) editor.getValue()); } return requestMap; } protected UrlMatcher getUrlMatcher() { return new AntUrlPathMatcher(); } public void setResourceQuery(String resourceQuery) { this.resourceQuery = resourceQuery; } private class Resource { private String url; private String role; public Resource(String url, String role) { this.url = url; this.role = role; } public String getUrl() { return url; } public String getRole() { return role; } } private class ResourceMapping extends MappingSqlQuery { protected ResourceMapping(DataSource dataSource, String resourceQuery) { super(dataSource, resourceQuery); compile(); } protected Object mapRow(ResultSet rs, int rownum) throws SQLException { String url = rs.getString(1); String role = rs.getString(2); Resource resource = new Resource(url, role); return resource; } } } 5.3.2. 替换原有功能的切入点 在spring中配置我们编写的代码。 下一步使用这个filterInvocationDefinitionSource创建filterSecurityInterceptor,并使用它替换系统原来创建的那个过滤器。 注意这个custom-filter标签,它表示将filterSecurityInterceptor放在框架原来的FILTER_SECURITY_INTERCEPTOR过滤器之前,这样我们的过滤器会先于原来的过滤器执行,因为它的功能与老过滤器完全一样,所以这就等于把原来的过滤器替换掉了。 完整的配置文件如下所示: 实例见ch05。 目前存在的问题是,系统会在初始化时一次将所有资源加载到内存中,即使在数据库中修改了资源信息,系统也不会再次去从数据库中读取资源信息。这就造成了每次修改完数据库后,都需要重启系统才能时资源配置生效。 解决方案是,如果数据库中的资源出现的变化,需要刷新内存中已加载的资源信息时,使用下面代码: <%@page import="org.springframework.context.ApplicationContext"%> <%@page import="org.springframework.web.context.support.WebApplicationContextUtils"%> <%@page import="org.springframework.beans.factory.FactoryBean"%> <%@page import="org.springframework.security.intercept.web.FilterSecurityInterceptor"%> <%@page import="org.springframework.security.intercept.web.FilterInvocationDefinitionSource"%> <% ApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(application); FactoryBean factoryBean = (FactoryBean) ctx.getBean("&filterInvocationDefinitionSource"); FilterInvocationDefinitionSource fids = (FilterInvocationDefinitionSource) factoryBean.getObject(); FilterSecurityInterceptor filter = (FilterSecurityInterceptor) ctx.getBean("filterSecurityInterceptor"); filter.setObjectDefinitionSource(fids); %> 目前还不支持对方法调用和ACL资源的动态管理,相关讨论请参考手册后面的部分第 36 章 动态资源管理。 第 6 章 控制用户信息 让我们来研究一些与用户信息相关的功能,包括为用户密码加密,缓存用户信息,获得系统当前登陆的用户,获得登陆用户的所有权限。 6.1. MD5加密 任何一个正式的企业应用中,都不会在数据库中使用明文来保存密码的,我们在之前的章节中都是为了方便起见没有对数据库中的用户密码进行加密,这在实际应用中是极为幼稚的做法。可以想象一下,只要有人进入数据库就可以看到所有人的密码,这是一件多么恐怖的事情,为此我们至少要对密码进行加密,这样即使数据库被攻破,也可以保证用户密码的安全。 最常用的方法是使用MD5算法对密码进行摘要加密,这是一种单项加密手段,无法通过加密后的结果反推回原来的密码明文。 为了使用MD5对密码加密,我们需要修改一下配置文件。 上述代码中新增的黄色部分,将启用md5算法。这时我们在数据库中保存的密码已经不再是明文了,它看起来像是一堆杂乱无章的乱码。 INSERT INTO USERS VALUES('admin','21232f297a57a5a743894a0e4a801fc3',TRUE) INSERT INTO USERS VALUES('user','ee11cbb19052e40b07aac0ca060c23ee',TRUE) 可以看到密码部分已经面目全非了,即使有人攻破了数据库,拿到这种“乱码”也无法登陆系统窃取客户的信息。 这些配置对普通客户不会造成任何影响,他们只需要输入自己的密码,Spring Security会自动加以演算,将生成的结果与数据库中保存的信息进行比对,以此来判断用户是否可以登陆。 这样,我们只添加了一行配置,就为系统带来了密码加密的功能。 6.2. 盐值加密 实际上,上面的实例在现实使用中还存在着一个不小的问题。虽然md5算法是不可逆的,但是因为它对同一个字符串计算的结果是唯一的,所以一些人可能会使用“字典攻击”的方式来攻破md5加密的系统[5]。这虽然属于暴力解密,却十分有效,因为大多数系统的用户密码都不回很长。 实际上,大多数系统都是用admin作为默认的管理员登陆密码,所以,当我们在数据库中看到“21232f297a57a5a743894a0e4a801fc3”时,就可以意识到admin用户使用的密码了。因此,md5在处理这种常用字符串时,并不怎么奏效。 为了解决这个问题,我们可以使用盐值加密“salt-source”。 修改配置文件: 在password-encoder下添加了salt-source,并且指定使用username作为盐值。 盐值的原理非常简单,就是先把密码和盐值指定的内容合并在一起,再使用md5对合并后的内容进行演算,这样一来,就算密码是一个很常见的字符串,再加上用户名,最后算出来的md5值就没那么容易猜出来了。因为攻击者不知道盐值的值,也很难反算出密码原文。 我们这里将每个用户的username作为盐值,最后数据库中的密码部分就变成了这样: INSERT INTO USERS VALUES('admin','ceb4f32325eda6142bd65215f4c0f371',TRUE) INSERT INTO USERS VALUES('user','47a733d60998c719cf3526ae7d106d13',TRUE) 6.3. 用户信息缓存 介于系统的用户信息并不会经常改变,因此使用缓存就成为了提升性能的一个非常好的选择。Spring Security内置的缓存实现是基于ehcache的,为了启用缓存功能,我们要在配置文件中添加相关的内容。 我们在jdbc-user-service部分添加了对userCache的引用,它将使用这个bean作为用户权限缓存的实现。对userCache的配置如下所示: EhCacheBasedUserCache是Spring Security内置的缓存实现,它将为jdbc-user-service提供缓存功能。它所引用的userEhCache来自spring提供的EhCacheFactoryBean和EhCacheManagerFactoryBean,对于userCache的缓存配置放在ehcache.xml中: 内存中最多存放100个对象。 不是永久缓存。 最大空闲时间为600秒。 最大活动时间为3600秒。 如果内存对象溢出则保存到磁盘。 如果想了解有关ehcache的更多配置,可以访问它的官方网站http://ehcache.sf.net/。 这样,我们就为用户权限信息设置好了缓存,当一个用户多次访问应用时,不需要每次去访问数据库了,ehcache会将对应的信息缓存起来,这将极大的提高系统的相应速度,同时也避免数据库符合过高的风险。 注意 cache-ref隐藏着一个陷阱,如果不看代码,我们也许会误认为cache-ref会在JdbcUserDetailsManager中设置对应的userCache,然后只要直接执行JdbcUserDetailsManager中的方法,就可以自动维护用户缓存。 可惜,cache-ref实际上是在JdbcUserDetailsManager的基础上,生成了一个CachingUserService,这个CachedUserDetailsService会拦截loadUserByUsername()方法,实现读取用户信息的缓存功能。我们在cache-ref中引用的UserCache实际上是放在CacheUserDetailsService中,而不是放到了原有的JdbcUserDetailsManager中,这就会导致JdbcUserDetailsManager中对用户缓存的操作全部失效。 6.4. 获取当前用户信息 如果只是想从页面上显示当前登陆的用户名,可以直接使用Spring Security提供的taglib。 <%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> Access Denied
下一步修改配置文件,添加自定义访问拒绝页面的地址。 现在访问拒绝的页面就变成了下面这样: 图 7.2. 自定义访问拒绝页面 实例在ch007。 第 8 章 动态管理资源结合自定义登录页面 如果想将动态管理资源与自定义登录页面一起使用,最简单的办法就是在数据库中将登录页面对应的权限设置为IS_AUTHENTICATED_ANONYMOUSLY。 因此在数据库中添加一条资源信息。 INSERT INTO RESC VALUES(1,'','URL','/login.jsp*',1,'') 这里的/login.jsp*就是我们自定义登录页面的地址。 然后为匿名用户添加一条角色信息: INSERT INTO ROLE VALUES(3,'IS_AUTHENTICATED_ANONYMOUSLY','anonymous') 最后为这两条记录进行关联即可。 INSERT INTO RESC_ROLE VALUES(1,3) 这样就实现了将动态管理资源与自定义登录页面进行结合。 实例在ch008。 部分 II. 保护web篇 信息: FilterChainProxy: FilterChainProxy[ UrlMatcher = org.springframework.security.util.AntUrlPathMatcher[requiresLowerCase='true']; Filter Chains: { /**=[ org.springframework.security.context.HttpSessionContextIntegrationFilter[ order=200;], org.springframework.security.ui.logout.LogoutFilter[ order=300; ], org.springframework.security.ui.webapp.AuthenticationProcessingFilter[ order=700; ], org.springframework.security.ui.webapp.DefaultLoginPageGeneratingFilter[ order=900; ], org.springframework.security.ui.basicauth.BasicProcessingFilter[ order=1000; ], org.springframework.security.wrapper.SecurityContextHolderAwareRequestFilter[ order=1100; ], org.springframework.security.ui.rememberme.RememberMeProcessingFilter[ order=1200; ], org.springframework.security.providers.anonymous.AnonymousProcessingFilter[ order=1300; ], org.springframework.security.ui.ExceptionTranslationFilter[ order=1400; ], org.springframework.security.ui.SessionFixationProtectionFilter[ order=1600; ], org.springframework.security.intercept.web.FilterSecurityInterceptor@e2fbeb ] } ] Spring Security一启动就会包含这样一批负责各种安全管理的过滤器,这部分的任务就是详细讲解每个过滤器的功能和用法,并讨论与之相关的各种控制权限的方法。 第 9 章 图解过滤器 图 9.1. auto-config='true'时的过滤器列表 9.1. HttpSessionContextIntegrationFilter 图 9.2. org.springframework.security.context.HttpSessionContextIntegrationFilter 位于过滤器顶端,第一个起作用的过滤器。 用途一,在执行其他过滤器之前,率先判断用户的session中是否已经存在一个SecurityContext了。如果存在,就把SecurityContext拿出来,放到SecurityContextHolder中,供Spring Security的其他部分使用。如果不存在,就创建一个SecurityContext出来,还是放到SecurityContextHolder中,供Spring Security的其他部分使用。 用途二,在所有过滤器执行完毕后,清空SecurityContextHolder,因为SecurityContextHolder是基于ThreadLocal的,如果在操作完成后清空ThreadLocal,会受到服务器的线程池机制的影响。 9.2. LogoutFilter 图 9.3. org.springframework.security.ui.logout.LogoutFilter 只处理注销请求,默认为/j_spring_security_logout。 用途是在用户发送注销请求时,销毁用户session,清空SecurityContextHolder,然后重定向到注销成功页面。可以与rememberMe之类的机制结合,在注销的同时清空用户cookie。 9.3. AuthenticationProcessingFilter 图 9.4. org.springframework.security.ui.webapp.AuthenticationProcessingFilter 处理form登陆的过滤器,与form登陆有关的所有操作都是在此进行的。 默认情况下只处理/j_spring_security_check请求,这个请求应该是用户使用form登陆后的提交地址,form所需的其他参数可以参考:???。 此过滤器执行的基本操作时,通过用户名和密码判断用户是否有效,如果登录成功就跳转到成功页面(可能是登陆之前访问的受保护页面,也可能是默认的成功页面),如果登录失败,就跳转到失败页面。 9.4. DefaultLoginPageGeneratingFilter 图 9.5. org.springframework.security.ui.webapp.DefaultLoginPageGeneratingFilter 此过滤器用来生成一个默认的登录页面,默认的访问地址为/spring_security_login,这个默认的登录页面虽然支持用户输入用户名,密码,也支持rememberMe功能,但是因为太难看了,只能是在演示时做个样子,不可能直接用在实际项目中。 如果想自定义登陆页面,可以参考:第 4 章 自定义登陆页面。 9.5. BasicProcessingFilter 图 9.6. org.springframework.security.ui.basicauth.BasicProcessingFilter 此过滤器用于进行basic验证,功能与AuthenticationProcessingFilter类似,只是验证的方式不同。有关basic验证的详细情况,我们会在后面的章节中详细介绍。 有关basic验证的详细信息,可以参考:第 12 章 basic认证。 9.6. SecurityContextHolderAwareRequestFilter 图 9.7. org.springframework.security.wrapper.SecurityContextHolderAwareRequestFilter 此过滤器用来包装客户的请求。目的是在原始请求的基础上,为后续程序提供一些额外的数据。比如getRemoteUser()时直接返回当前登陆的用户名之类的。 9.7. RememberMeProcessingFilter 图 9.8. org.springframework.security.ui.rememberme.RememberMeProcessingFilter 此过滤器实现RememberMe功能,当用户cookie中存在rememberMe的标记,此过滤器会根据标记自动实现用户登陆,并创建SecurityContext,授予对应的权限。 有关rememberMe功能的详细信息,可以参考:第 14 章 自动登录。 9.8. AnonymousProcessingFilter 图 9.9. org.springframework.security.providers.anonymous.AnonymousProcessingFilter 为了保证操作统一性,当用户没有登陆时,默认为用户分配匿名用户的权限。 有关匿名登录功能的详细信息,可以参考:第 15 章 匿名登录。 9.9. ExceptionTranslationFilter 图 9.10. org.springframework.security.ui.ExceptionTranslationFilter 此过滤器的作用是处理中FilterSecurityInterceptor抛出的异常,然后将请求重定向到对应页面,或返回对应的响应错误代码。 9.10. SessionFixationProtectionFilter 图 9.11. org.springframework.security.ui.SessionFixationProtectionFilter 防御会话伪造攻击。有关防御会话伪造的详细信息,可以参考:第 16 章 防御会话伪造。 9.11. FilterSecurityInterceptor 图 9.12. org.springframework.security.intercept.web.FilterSecurityInterceptor 用户的权限控制都包含在这个过滤器中。 功能一:如果用户尚未登陆,则抛出AuthenticationCredentialsNotFoundException“尚未认证异常”。 功能二:如果用户已登录,但是没有访问当前资源的权限,则抛出AccessDeniedException“拒绝访问异常”。 功能三:如果用户已登录,也具有访问当前资源的权限,则放行。 至此,我们完全展示了默认情况下Spring Security中使用到的过滤器,以及每个过滤器的应用场景和显示功能,下面我们会对这些过滤器的配置和用法进行逐一介绍。 第 10 章 管理会话 多个用户不能使用同一个账号同时登陆系统。 10.1. 添加监听器 在web.xml中添加一个监听器,这个监听器会在session创建和销毁的时候通知Spring Security。 org.springframework.security.ui.session.HttpSessionEventPublisher 这种监听session生命周期的监听器主要用来收集在线用户的信息,比如统计在线用户数之类的事。有关如何自己编写listener统计在线用户数,可以参考:http://family168.com/tutorial/jsp/html/jsp-ch-08.html 10.2. 添加过滤器 在xml中添加控制同步session的过滤器。 因为Spring Security的作者不认为控制会话是一个大家都经常使用的功能,所以concurrent-session-control没有包含在默认生成的过滤器链中,在我们需要使用它的时候,需要自己把它添加到http元素中。 这个concurrent-session-control对应的过滤器类是org.springframework.security.concurrent.ConcurrentSessionFilter,它的排序代码是100,它会被放在过滤器链的最顶端,在所有过滤器使用之前起作用。 10.3. 控制策略 10.3.1. 后登陆的将先登录的踢出系统 默认情况下,后登陆的用户会把先登录的用户踢出系统。 想测试一下的话,先打开firefox使用user/user登陆系统,然后再打开ie使用user/user登陆系统。这时ie下的user用户会登陆成功,进入登陆成功页面。而firefox下的用户如何刷新页面,就会显示如下信息: This session has been expired (possibly due to multiple concurrent logins being attempted as the same user). 这是因为先登录的用户已经被强行踢出了系统,如果他再次使用user/user登陆,ie下的用户也会被踢出系统了。 10.3.2. 后面的用户禁止登陆 如果不想让之前登录的用户被自动踢出系统,需要为concurrent-session-control设置一个参数。 这个参数用来控制是否在会话数目超过最大限制时抛出异常,默认值是false,也就是不抛出异常,而是把之前的session都销毁掉,所以之前登陆的用户就会被踢出系统了。 现在我们把这个参数改为true,再使用同一个账号同时登陆一下系统,看看会发生什么现象。 图 10.1. 禁止同一账号多次登录 很好,现在只要有一个人使用user/user登陆过系统,其他人就不能再次登录了。这样可能出现一个问题,如果有人登陆的时候因为某些问题没有进行logout就退出了系统,那么他只能等到session过期自动销毁之后,才能再次登录系统。 实例在ch102。 第 11 章 单点登录 所谓单点登录,SSO(Single Sign On),就是把N个应用的登录系统整合在一起,这样一来无论用户登录了任何一个应用,都可以直接以登录过的身份访问其他应用,不用每次访问其他系统再去登陆一遍了。 Spring Security没有实现自己的SSO,而是整合了耶鲁大学单点登陆(JA-SIG),这是当前使用很广泛的一种SSO实现,它是基于中央认证服务CAS(Center Authentication Service)的结构实现的,可以访问它们的官方网站获得更详细的信息http://www.jasig.org/cas。 在了解过这些基础知识之后,我们可以开始研究如何使用Spring Security实现单点登录了。 11.1. 配置JA-SIG 从JA-SIG的官方网站下载cas-server,本文写作时的最新稳定版为3.3.2。http://www.ja-sig.org/downloads/cas/cas-server-3.3.2-release.zip。 将下载得到的cas-server-3.3.2-release.zip文件解压后,可以得到一大堆的目录和文件,我们这里需要的是modules目录下的cas-server-webapp-3.3.2.war。 把cas-server-webapp-3.3.2.war放到ch103\server目录下,然后执行run.bat就可启动CAS中央认证服务器。 我们已在pom.xml中配置好了启用SSL所需的配置,包括使用的server.jks和对应密码,之后我们可以通过https://localhost:9443/cas/login访问CAS中央认证服务器。 图 11.1. 登陆页面 默认情况下,只要输入相同的用户名和密码就可以登陆系统,比如我们使用user/user进行登陆。 图 11.2. 登陆成功 这就证明中央认证服务器已经跑起来了。下一步我们来配置Spring Security,让它通过中央认证服务器进行登录。 11.2. 配置Spring Security 11.2.1. 添加依赖 首先要添加对cas的插件和cas客户端的依赖库。因为我们使用了maven2,所以只需要在pom.xml中添加一个依赖即可。 org.springframework.security spring-security-cas-client 2.0.5.RELEASE 如果有人很不幸的没有使用maven2,那么就需要手工把去下面这些依赖库了。 spring-security-cas-client-2.0.5.RELEASE.jar spring-dao-2.0.8.jar aopalliance-1.0.jar cas-client-core-3.1.5.jar 大家可以去spring和ja-sig的网站去寻找这些依赖库。 11.2.2. 修改applicationContext.xml 首先修改http部分。 添加一个entry-point-ref引用cas提供的casProcessingFilterEntryPoint,这样在验证用户登录时就用上cas提供的机制了。 修改注销页面,将注销请求转发给cas处理。 Logout of CAS 然后要提供userService和authenticationManager,二者会被注入到cas的类中用来进行登录之后的用户授权。 为了演示方便,我们将用户信息直接写在了配置文件中,之后cas的类就可以通过id获得userService,以此获得其中定义的用户信息和对应的权限。 对于authenticationManager来说,我们没有创建一个新实例,而是使用了“别名”(alias),这是因为在之前的namespace配置时已经自动生成了authenticationManager的实例,cas只需要知道这个实例的别名就可以直接调用。 创建cas的filter, entryPoint, serviceProperties和authenticationProvider。 casProcessingFilter最终是要放到Spring security的安全过滤器链中才能发挥作用的。这里使用的customer-filter就会把它放到CAS_PROCESSING_FILTER位置的后面。 这个位置具体是在LogoutFilter和AuthenticationProcessingFilter之间,这样既不会影响注销操作,又可以在用户进行表单登陆之前拦截用户请求进行cas认证了。 当用户尚未登录时,会跳转到这个cas的登录页面进行登录。 用户在cas登录成功后,再次跳转回原系统时请求的页面。 CasProcessingFilter会处理这个请求,从cas获得已登录的用户信息,并对用户进行授权。 使用custom-authentication-provider之后,Spring Security其他的权限模块会从这个bean中获得权限验证信息。 系统需要验证当前用户的tickets是否有效。 经过了这么多的配置,我们终于把cas功能添加到spring security中了,看着一堆堆一串串的配置文件,好似又回到了acegi的时代,可怕啊。 下面运行系统,尝试使用了cas的权限控制之后有什么不同。 11.3. 运行配置了cas的子系统 首先要保证cas中央认证服务器已经启动了。子系统的pom.xml中也已经配置好了SSL,所以可以进入ch103执行run.bat启动子系统。 现在直接访问http://localhost:8080/ch103/不再会弹出登陆页面,而是会跳转到cas中央认证服务器上进行登录。 图 11.3. 登陆页面 输入user/user后进行登录,系统不会做丝毫的停留,直接跳转回我们的子系统,这时我们已经登录到系统中了。 图 11.4. 登陆成功 我们再来试试注销,点击logout会进入cas-logout.jsp。 图 11.5. cas-logout.jsp 在此点击Logout of CAS会跳转至cas进行注销。 图 11.6. 注销成功 现在我们完成了Spring Security中cas的配置,enjoy it。 11.4. 为cas配置SSL 在使用cas的时候,我们要为cas中央认证服务器和子系统都配置上SSL,以此来对他们之间交互的数据进行加密。这里我们将使用JDK中包含的keytool工具生成配置SSL所需的密钥。 11.4.1. 生成密钥 首先生成一个key store。 keytool -genkey -keyalg RSA -dname "cn=localhost,ou=family168,o=www.family168.com,l=china,st=beijing,c=cn" -alias server -keypass password -keystore server.jks -storepass password 我们会得到一个名为server.jks的文件,它的密码是password,注意cn=localhost部分,这里必须与cas服务器的域名一致,而且不能使用ip,因为我们是在本地localhost测试cas,所以这里设置的就是cn=localhost,在实际生产环境中使用时,要将这里配置为cas服务器的实际域名。 导出密钥 keytool -export -trustcacerts -alias server -file server.cer -keystore server.jks -storepass password 将密钥导入JDK的cacerts keytool -import -trustcacerts -alias server -file server.cer -keystore D:/apps/jdk1.5.0_15/jre/lib/security/cacerts -storepass password 这里需要把使用实际JDK的安装路径,我们要把密钥导入到JDK的cacerts中。 我们在ch103/certificates/下放了一个genkey.bat,这个批处理文件中已经包含了上述的所有命令,运行它就可以生成我们所需的密钥。 11.4.2. 为jetty配置SSL jetty的配置可参考ch103中的pom.xml文件。 9443 ../certificates/server.jks password password ../certificates/server.jks password true false javax.net.ssl.trustStore ../certificates/server.jks javax.net.ssl.trustStorePassword password 11.4.3. 为tomcat配置SSL 要运行支持SSL的tomcat,把server.jks文件放到tomcat的conf目录下,然后把下面的连接器添加到server.xml文件中。 如果你希望客户端没有提供证书的时候SSL链接也能成功,也可以把clientAuth设置成want。 实例在ch103。 第 12 章 basic认证 basic认证是另一个常用的认证方式,与表单认证不同的是,basic认证常用于无状态客户端的验证,比如HttpInvoker或者Web Service的认证,这种场景的特点是客户端每次访问应用时,都在请求头部携带认证信息,一般就是用户名和密码,因为basic认证会传递明文,所以最好使用https传输数据。 12.1. 配置basic验证 如果在http中配置了auto-config="true"我们就不用再添加任何配置了,默认配置中已经包含了Basic认证功能。但是这同时也会激活form-login,因此我们将演示仅有basic验证的场景,为此需要去掉配置文件中的auto-config="true"。 删除了auto-config="true"之后,还要记得添加http-basic标签,这样我们的系统将仅仅使用basic认证方式来实现用户登录。 现在我们访问系统时,不会再进入之前的登录页面,而是会显示浏览器原生的登录对话框。 图 12.1. basic登录 登录成功之后,我们可以在HTTP请求头部看到basic验证所需的属性Authorization。 图 12.2. HTTP请求头 最后需要注意的是,因为basic认证不使用session,所以无法与rememberMe功用。 12.2. 编程实现basic客户端 下面我们来示范一下如何使用basic认证。假设我们在basic.jsp中需要远程调用http://localhost:8080/ch104/admin.jsp的内容。这时为了能够通过Spring Security的权限检测,我们需要在请求的头部加上basic所需的认证信息。 String username = "admin"; String password = "admin"; byte[] token = (username + ":" + password).getBytes("utf-8"); String authorization = "Basic " + new String(Base64.encodeBase64(token), "utf-8"); URL url = new URL("http://localhost:8080/ch104/admin.jsp"); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestProperty("Authorization", authorization); 我们先将用户名和密码拼接成一个字符串,两者之间使用“:”分隔。 然后使用commons-codec的Base64将这个字符串加密。在进行basic认证的时候Spring Security会使用commons-codec把这段字符串反转成用户名和密码,再进行认证操作。 下一步为加密后得到的字符串添加一个前缀"Basic ",这样Spring Security就可以通过这个判断客户端是否使用了basic认证。 将上面生成的字符串设置到请求头部,名称为“Authorization”。Spring Security会在认证时,获取头部信息进行判断。 有关basic代码可以在/ch104/basic.jsp找到,可以运行ch104,然后访问http://localhost:8080/ch104/basic.jsp。它会使用上述的代码,通过Spring Security的认证,成功访问到admin.jsp的信息。 实例在ch104。 第 13 章 标签库 Spring Security提供的标签库,主要的用途是为了在视图层直接访问用户信息,再者就是为了对显示的内容进行权限管理。 13.1. 配置taglib 如果需要使用taglib,首先要把spring-security-taglibs-2.0.5.RELEASE.jar放到项目的classpath下,这在文档附带的实例中已经配置好了依赖。剩下 的只要在jsp上添加taglib的定义就可以使用标签库了。 <%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags"%> 13.2. authenticaiton authentication的功能是从SecurityContext中获得一些权限相关的信息。 可以用它获得当前登陆的用户名: 获得当前用户所有的权限,把权限列表放到authorities变量里,然后循环输出权限信息: ${authority.authority} 13.3. authorize authorize用来判断当前用户的权限,然后根据指定的条件判断是否显示内部的内容。 admin and user admin or user not admin ifAllGranted,只有当前用户同时拥有ROLE_ADMIN和ROLE_USER两个权限时,才能显示标签内部内容。 ifAnyGranted,如果当前用户拥有ROLE_ADMIN或ROLE_USER其中一个权限时,就能显示标签内部内容。 ifNotGranted,如果当前用户没有ROLE_ADMIN时,才能显示标签内部内容。 13.4. acl/accesscontrollist 用于判断当前用户是否拥有指定的acl权限。 | Remove 我们将当前显示的对象作为参数传入acl标签,然后指定判断的权限为8(删除)和16(管理),当前用户如果拥有对这个对象的删除和管理权限时,就会显示对应的remove超链接,用户才可以通过此链接对这条记录进行删除操作。 关于ACL的知识,请参考第 46 章 ACL基本操作。 13.5. 为不同用户显示各自的登陆成功页面 一个常见的需求是,普通用户登录之后显示普通用户的工作台,管理员登陆之后显示后台管理页面。这个功能可以使用taglib解决。 其实只要在登录成功后的jsp页面中使用taglib判断当前用户拥有的权限进行跳转就可以。 <%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> <%response.sendRedirect("admin.jsp");%> <%response.sendRedirect("user.jsp");%> 当用户拥有ROLE_ADMIN权限时,既跳转到admin.jsp显示管理后台。 当用户没有ROLE_ADMIN权限时,既跳转到user.jsp显示普通用户工作台。 这里我们只做最简单的判断,只区分当前用户是否为管理员。可以根据实际情况做更加复杂的跳转,当用户具有不同权限时,跳到对应的页面,甚至可以根据用户username跳转到各自的页面。 实例在ch105。 第 14 章 自动登录 如果用户一直使用同一台电脑上网,那么他可能希望不要每次上网都要进行登录这道程序,他们希望系统可以记住自己一段时间,这样用户就可以无需登录直接登录系统,使用其中的功能。rememberMe就给我们提供了这样一种便捷途径。 14.1. 默认策略 在配置文件中使用auto-config="true"就会自动启用rememberMe,之后,只要用户在登录时选中checkbox就可以实现下次无需登录直接进入系统的功能。 图 14.1. 选中rememberMe 默认有效时间是两周,启用rememberMe之后的两周内,用户都可以直接跳过系统,直接进入系统。 实际上,Spring Security中的rememberMe是依赖cookie实现的,当用户在登录时选择使用rememberMe,系统就会在登录成功后将为用户生成一个唯一标识,并将这个标识保存进cookie中,我们可以通过浏览器查看用户电脑中的cookie。 图 14.2. rememberMe cookie 从上图中,我们可以看到Spring Security生成的cookie名称是SPRING_SECURITY_REMEMBER_ME_COOKIE,它的内容是一串加密的字符串,当用户再次访问系统时,Spring Security将从这个cookie读取用户信息,并加以验证。如果可以证实cookie有效,就会自动将用户登录到系统中,并为用户授予对应的权限。 14.2. 持久化策略 rememberMe的默认策略会将username和过期时间保存到客户主机上的cookie中,虽然这些信息都已经进行过加密处理,不过我们还可以使用安全级别更高的持久化策略。在持久化策略中,客户主机cookie中保存的不再用username,而是由系统自动生成的序列号,在验证时系统会将客户cookie中保存的序列号与数据库中保存的序列号进行比对,以确认客户请求的有效性,之后在比对成功后才会从数据库中取出对应的客户信息,继续进行认证和授权等工作。这样即使客户本地的cookie遭到破解,攻击者也只能获得一个序列号,而不是用户的登录账号。 如果希望使用持久化策略,我们需要先在数据库中创建rememberMe所需的表。 create table persistent_logins ( username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null ); 然后要为配置文件中添加与数据库的链接。 最后修改http中的配置,为remember-me添加data-source-ref即可,Spring Security会在初始化时判断是否存在data-source-ref属性,如果存在就会使用持久化策略,否则会使用上述的默认策略。 注意 默认策略和持久化策略是不能混用的,如果你首先在应用中使用过默认策略的rememberMe,未等系统过期便换成了持久化策略,之前保留的cookie也无法通过系统验证,实际上系统会将cookie当做无效标识进行清除。同样的,持久化策略中生成的cookie也无法用在默认策略下。 实例在ch106。 第 15 章 匿名登录 匿名登录,即用户尚未登录系统,系统会为所有未登录的用户分配一个匿名用户,这个用户也拥有自己的权限,不过他是不能访问任何被保护资源的。 设置一个匿名用户的好处是,我们在进行权限判断时,可以保证SecurityContext中永远是存在着一个权限主体的,启用了匿名登录功能之后,我们所需要做的工作就是从SecurityContext中取出权限主体,然后对其拥有的权限进行校验,不需要每次去检验这个权限主体是否为空了。这样做的好处是我们永远认为请求的主体是拥有权限的,即便他没有登录,系统也会自动为他赋予未登录系统角色的权限,这样后面所有的安全组件都只需要在当前权限主体上进行处理,不用一次一次的判断当前权限主体是否存在。这就更容易保证系统中操作的一致性。 15.1. 配置文件 在配置文件中使用auto-config="true"就会启用匿名登录功能。在启用匿名登录之后,如果我们希望允许未登录就可以访问一些资源,可以在进行如下配置。 在access中指定IS_AUTHENTICATED_ANONYMOUSLY后,系统就知道此资源可以被匿名用户访问了。当未登录时访问系统的“/”,就会被自动赋以匿名用户的身份。我们可以使用taglib获得用户的权限主体信息。 这里的IS_AUTHENTICATED_ANONYMOUSLY将会交由AuthenticatedVoter处理,内部会依据AuthenticationTrustResolver判断当前登录的用户是否是匿名用户。 不过,为了更明显的将匿名用户与系统中的其他用户区分开,我们推荐在配置时尽量使用IS_AUTHENTICATED_ANONYMOUSLY来指定匿名用户可以访问的资源。 15.2. 修改默认用户名 我们通常可以看到这种情况,当一个用户尚未登录系统时,在页面上应当显示用户名的部分显示的是“游客”。这样操作更利于提升客户体验,他看到自己即使没有登录也可以使用“游客”的身份享受系统的服务,而且使用了“游客”作为填充内容,也避免了原本显示用户名部分留空,影响页面布局。 如果没有匿名登录的功能,我们就被迫要在所有显示用户名的部分,判断当前用户是否登录,然后根据登录情况显示登录用户名或默认的“游客”字符。匿名登录功能帮我们解决了这个问题,既然未登录用户都拥有匿名用户的身份,那么在显示用户名时就不必去区分用户登录状态,直接显示当前权限主体的名称即可。 我们需要做的只是为匿名设置默认的用户名而已,默认的名称roleAnonymous可以通过配置文件中的anonymous元素修改。 这样,匿名用户的默认名称就变成了“Guest”,我们在页面上依然使用相同的taglib显示用户名即可。 图 15.2. 修改匿名用户名称 15.3. 匿名用户的限制 虽然匿名用户无论在配置授权时,还是在获取权限信息时,都与已登录用户的操作一模一样,但它应该与未登录用户是等价,当我们以匿名用户的身份进入“/”后,点击admin.jsp链接,系统会像处理未登录用户时一样,跳转到登录用户,而不是像处理以登录用户时显示拒绝访问页面。 但是匿名用户与未登录用户之间也有很大的区别,比如,我们将“/”设置为不需要过滤器保护,而不是设置匿名用户。 filters="none"表示当我们访问“/”时,是不会使用任何一个过滤器去处理这个请求的,它可以实现无需登录即可访问资源的效果,但是因为没有使用过滤器对请求进行处理,所以也无法利用安全过滤器为我们带来的好处,最简单的,这时SecurityContext内再没有保存任何一个权限主体了,我们也无法从中取得主体名称以及对应的权限信息。 图 15.3. 跳过过滤器的情况 因此,使用filters="none"忽略所有过滤器会提升性能,而是用匿名登录功能可以实现权限操作的一致性,具体应用时还需要大家根据实际情况自行选择了。 实例在ch107。 第 16 章 防御会话伪造 16.1. 攻击场景 session fixation会话伪造攻击是一个蛮婉转的过程。 比如,当我要是使用session fixation攻击你的时候,首先访问这个网站,网站会创建一个会话,这时我可以把附有jsessionid的url发送给你。 http://unsafe/index.jsp;jsessionid=1pjztz08i2u4i 你使用这个网址访问网站,结果你和我就会公用同一个jsessionid了,结果就是在服务器中,我们两人使用的是同一个session。 这时我只要祈求你在session过期之前登陆系统,然后我就可以使用jsessionid直接进入你的后台了,然后可以使用你在系统中的授权做任何事情。 简单来说,我创建了一个session,然后把jsessionid发给你,你傻乎乎的就使用我的session进行了登陆,结果等于帮我的session进行了授权操作,结果就是我可以使用一开始创建的session进入系统做任何事情了。 与会话伪造的详细信息可以参考http://en.wikipedia.org/wiki/Session_fixation。 16.2. 解决会话伪造 解决session fix的问题其实很简单,只要在用户登录成功之后,销毁用户的当前session,并重新生成一个session就可以了。 Spring Security默认就会启用session-fixation-protection,这会在登录时销毁用户的当前session,然后为用户创建一个新session,并将原有session中的所有属性都复制到新session中。 如果希望禁用session-fixation-protection,可以在http中将session-fixation-protection设置为none。 session-fixation-protection的值共有三个可供选择,none,migrateSession和newSession。默认使用的是migrationSession,如同我们上面所讲的,它会将原有session中的属性都复制到新session中。上面我们也见到了使用none来禁用session-fixation功能的场景,最后剩下的newSession会在用户登录时生成新session,但不会复制任何原有属性。 实例在ch108。 第 17 章 预先认证 预先认证是指用户在进入系统之前,就已经通过某种机制进行过身份认证,请求中已经附带了身份认证的信息,这时我们只需要从获得这些身份认证信息,并对用户进行授权即可。CAS, X509等都属于这种情况。 Spring Security中专门为这种系统外预先认证的情况提供了工具类,这一章我们来看一下如何使用Pre-Auth处理使用容器Realm认证的用户。 17.1. 为jetty配置Realm 首先在pom.xml中配置jetty所需的Realm。 Preauth Realm realm.properties 用户,密码,以及权限信息都保存在realm.properties文件中。 admin: admin,ROLE_ADMIN,ROLE_USER user: user,ROLE_USER test: test 我们配置了三个用户,分别是admin, user和test,其中admin拥有ROLE_ADMIN和ROLE_USER权限,user拥有ROLE_USER权限,而test没有任何权限。 下一步在src/webapp/WEB-INF/web.xml中配置登录所需的安全权限。 BASIC Preauth Realm ROLE_USER ROLE_ADMIN All areas /* ROLE_USER 这里我们将login-config中的realm-name配置为Preauth Realm,这与刚刚在pom.xml中配置的名称是相同的。而后我们配置了两个安全权限ROLE_USER和ROLE_ADMIN,最后我们现在访问所有资源都需要使用ROLE_USER这个权限。 自此,服务器与应用中的Realm配置完毕,下一步我们需要使用Spring Security与Realm对接。 17.2. 配置Spring Security 因为使用容器Realm的Pre-Auth并没有对应的命名空间,所以需要去掉auto-config="true"并引用对应的安全入口。 这次我们会使用j2eePreAuthFilter执行用户认证,所有默认那些form-login, basic-login, rememberMe都没了用武之地。 而为了使用j2eePreAuthFilter,我们需要进行如下配置: 这里,我们要配置Pre-Auth所需的AuthenticationProvider, EntryPoint, AuthenticatedUserDetailsService并最终组装成一个j2eePreAuthFilter。其中j2eeMappableRolesRetriever会读取我们之前配置的web.xml,从中获得权限信息。 这样,当用户登录时,请求会先被Realm拦截,并要求用户进行登录: 图 17.1. Realm登录 登录成功后,Realm会将用户身份信息绑定到请求中,j2eePreAuthFilter就会从请求中读取身份信息,结合web.xml中定义的权限信息对用户进行授权,并将授权信息录入SecurityContext,之后对用户验证时与之前已没有了任何区别。 这里的preauthEntryPoint会在用户权限不足时起作用,它只会简单返回一个401的拒绝访问响应。 在此我们并不推荐实际中使用这项功能,因为需要对容器进行配置,影响应用的灵活性。 实例在ch109。 第 18 章 切换用户 Spring Security提供了一种称为切换用户的机制,可以使管理员免于进过登录的操作,直接切换当前用户,从而改变当前的操作权限。因为按照责权分离的原则,系统内的超级管理员应该只有管理权限,而没有操作权限,所以为了在改变操作后可以测试系统的操作,需要降低权限才可以进入操作界面,这时就可以使用切换用户的功能。 18.1. 配置方式 在xml中添加SwitchUser的配置。 它需要引用系统中的userDetailsService在切换用户时,根据对应的username获得切换后用户的信息和权限,我们还要使用custom-filter将该过滤器放到过滤器链中,注意必须放在用来验证权限的FilterSecurityInterceptor之后,这样可以控制当前用户是否拥有切换用户的权限。 现在,我们可以在系统中使用切换用户这一功能了,我们可以通过/j_spring_security_switch_user?j_username=user切换到j_username指定的用户,这样可以快捷的获得目标用户的信息和权限。当需要返回管理员用户时,只需要通过/j_spring_security_exit_user就可以还原到切换前的状态。 18.2. 实例演示 现在我们进入实例,通过登录页面进行登录。因为实现了权责分离,admin/admin用户只能访问管理页面admin.jsp,不能访问user.jsp,user/user用户只能访问操作页面user.jsp,不能访问admin.jsp。 如果我们以管理员身份登录后,希望切换到user/user用户,可以调用/j_spring_security_switch_user?j_username=user切换到user/user用户,然后就可以使用user的权限访问user.jsp了。 在切换用户后,我们可以看到当前登录用户的权限中多了一个ROLE_PREVIOUS_ADMINISTRATOR,这其中就保存着前用户的权限信息,当我们通过/j_spring_security_exit_user退出切换用户模式时,系统就会从ROLE_PREVIOUS_ADMINISTRATOR中获得原始用户信息,重新进行授权。 实例在ch110。 第 19 章 信道安全 19.1. 设置信道安全 为了加强安全级别,我们可以限制默写资源必须通过https协议才能访问。 可以为/admin.jsp单独设置必须使用https才能访问,如果用户使用了http协议访问该网址,系统会强制用户使用https 协议重新进行请求。 这里我们可以选使用https, http或者any三种数值,其中any为默认值,表示无论用户使用何种协议都可以访问资源。 19.2. 指定http和https的端口 因为http和https协议的访问端口不同,Spring Security在处理信道安全时默认会使用80/443和8080/8443对访问的网址进行转换。如果服务器对http和https协议监听的端口进行了修改,则需要修改配置文件让系统了解http和https的端口信息。 我们可以使用port-mappings自定义端口映射。 上述配置文件中,我们定义了9000与9443的映射,现在系统会在强制使用http协议的网址时使用9000作为端口号,在强制使用https协议的网址时使用9443作为端口号,这些端口号会反映在重定向后生成网址中。 实例在ch111。 第 20 章 digest认证 digest认证比form-login和http-basic更安全的一种认证方式,尤其适用于不能使用https协议的场景。它与http-basic一样,都是不基于session的无状态认证方式。 20.1. 配置digest验证 因为digest不包含在命名空间中,所以我们需要配置额外的过滤器和验证入口。 然后记得删除auto-config="true",去除默认的form-login和http-basic认证,并添加对验证入口的引用。 现在我们访问系统时,不会再进入之前的登录页面,而是会显示浏览器原生的登录对话框。 图 20.1. digest登录 登录成功之后,我们可以在HTTP请求头部看到basic验证所需的属性Authorization。 图 20.2. HTTP请求头 最后需要注意的是,因为digest认证不使用session,所以无法与rememberMe功用。 20.2. 使用ajax实现digest认证 可以直接使用ajax来进行digest认证,完全不需要任何额外的配置,只需要在open的时候传入用户名和密码就可以完成认证。 oRequest.open("get", "index.jsp", false, "user", "user"); oRequest.setRequestHeader("Content-type", "text/xml; charset=utf-8"); oRequest.send(""); 这样就完成的认证过程,之后再使用普通方式访问其他页面也没问题了。 20.3. 编程实现digest客户端 如果希望自己编写客户端进行digest认证,可以参考RFC 2617,它是对RFC 2069这个早期摘要式认证标准的更新。 在HTTP请求头中将包含这样一个Authorization,它包含了username, realm, nonce, uri, responseDigest, qop, nc和cnonce八个部分。其中nonce是digest认证的中心,它的组成结构如下所示: base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key)) 其中expirationTime是nonce的过期时间,单位是毫秒。 key是放置nonce修改的私钥。 如果服务器生成的nonce已经过期(但是摘要还是有效),DigestProcessingFilterEntryPoint会发送一个"stale=true"头信息。 这告诉用户代理,这里不再需要打扰用户(像是密码和用户其他都是正确的),只是简单尝试使用一个新nonce。 实例在ch112。 第 21 章 通过LDAP获取用户信息 很多企业内部使用LDAP保存用户信息,这章我们来看一下如何从LDAP中获取Spring Security所需的用户信息。 首先在pom.xml中添加ldap所需的依赖。 org.apache.directory.server apacheds-server-jndi 1.0.2 org.springframework.ldap spring-ldap 1.2.1 然后修改配置文件,使用内嵌的ldap服务器和ldap-authentication-provider。 这里配置内嵌的ldap服务器从users.ldif文件中读取初始化数据,端口使用33389,查询目录的根目录设置为dc=family168,dc=com。 ldap-authentication-provider设置查找组和用户的配置,分别使用ou=groups表示组,使用ou=people表示用户。 用于保存ldap初始信息的文件内容如下: dn: ou=groups,dc=family168,dc=com objectclass: top objectclass: organizationalUnit ou: groups dn: ou=people,dc=family168,dc=com objectclass: top objectclass: organizationalUnit ou: people dn: uid=user,ou=people,dc=family168,dc=com objectclass: top objectclass: person objectclass: organizationalPerson objectclass: inetOrgPerson cn: FirstName LastName sn: LastName uid: user userPassword: user dn: uid=admin,ou=people,dc=family168,dc=com objectclass: top objectclass: person objectclass: organizationalPerson objectclass: inetOrgPerson cn: FirstName LastName sn: LastName uid: admin userPassword: admin dn: cn=user,ou=groups,dc=family168,dc=com objectclass: top objectclass: groupOfNames cn: ROLE_USER member: uid=user,ou=people,dc=family168,dc=com member: uid=admin,ou=people,dc=family168,dc=com dn: cn=admin,ou=groups,dc=family168,dc=com objectclass: top objectclass: groupOfNames cn: ROLE_ADMIN member: uid=admin,ou=people,dc=family168,dc=com 这里在dc=family168,dc=com目录下创建了groups和people两个目录,然后在people目录下创建了user和admin两个用户。在groups目录下创建了admin和user两个目录,并将user和admin两个用户与groups的user目录关联,又将admin用户与groups的admin目录关联。 在系统初始化后,Spring Security会在people下读取用户信息,而对应的权限信息是对应用户所关联的groups信息,Spring Security会将查询到的权限信息加上ROLE_前缀,如cn=admin最终会转换为ROLE_ADMIN。 实例在ch113。 第 22 章 通过OpenID进行登录 OpenID是一种网上身份识别服务,它的目标是让用户可以网上使用同一身份登录不同的系统。 这一章我们讲解如何使用Spring Security支持OpenID。 22.1. 配置 首先在pom.xml中添加使用OpenID所需的依赖。 org.springframework.security spring-security-openid 2.0.5.RELEASE 然后在xml中添加OpenID所需的配置。 只需要添加openid-login标签,并引用一个userService。userService中需要配置OpenID中的账号,以及对应的权限。它会在用户登录成功后对用户进行授权。 现在我们可以启动应用,进入登陆页面。 图 22.1. 登录页面 在登录页面中输入http://family168.myopenid.com/,这是我们已经注册过的一个OpenID账号,点击提交之后会跳转到myopenid.com网站。 图 22.2. myopenid.com 在此处输入密码:password。登录成功之后就会跳转回我们的系统。 图 22.3. 登录成功 这时我们可以看到当前登录系统的用户是http://family168.openid.com/。 22.2. 系统时间问题 因为OpenID服务器会根据当前时间生成nonce进行权限校验,所以一定要保证服务器的当前时间是正确的,如果服务器当前时间比正常时间快了哪怕只是几秒钟,也会导致在进行校验时抛出nonce is too old异常,这会导致一直无法正常登陆。 实例在ch114。 第 23 章 使用X509登录 X509是一种基于双向加密的身份认证方式,它要基于SSL,并要求开启客户端证书,是一种非常强力的安全手段。 23.1. 生成证书 我们要为X509生成服务器端和客户端使用的证书。 首先生成服务端证书server.jks。 keytool -genkey -keyalg RSA -dname "cn=localhost,ou=family168,o=www.family168.com,l=china,st=beijing,c=cn" -alias server -keypass password -keystore server.jks -storepass password 然后生成客户端证书,并将客户端证书导入到server.jks中。 keytool -genkey -v -alias user -keyalg RSA -storetype PKCS12 -keystore user.p12 -dname "cn=user,ou=family168,o=www.family168.com,l=china,st=beijing,c=cn" -storepass password -keypass password keytool -export -alias user -keystore user.p12 -storetype PKCS12 -storepass password -rfc -file user.cer keytool -import -v -file user.cer -keystore server.jks -storepass password 最后将服务端证书导出,加入jre安全证书中。 keytool -export -trustcacerts -alias server -file server.cer -keystore server.jks -storepass password keytool -import -trustcacerts -alias server -file server.cer -keystore "%JAVA_HOME%/JRE/LIB/SECURITY/CACERTS" -storepass changeit 23.2. 配置服务器使用双向加密 在pom.xml中对jetty进行配置,使用刚刚生成的服务端证书server.js配置SSL,并使用needClientAuth="true"启用双向加密啊。 8080 3600000 8443 certificates/server.jks password password certificates/server.jks password true 10 ../webdefault.xml javax.net.ssl.trustStore certificates/server.jks javax.net.ssl.trustStorePassword password 23.3. 配置X509认证 修改配置文件,添加x509配置方式 x509中,subject-principal-regex会从客户端证书中获取用户名,将CN部分当做username来使用,因为服务器端会绝对相信客户端证书中的信息,所以不会再去使用userService中的密码对用户进行校验,一旦证书校验通过,直接对用户进行授权。 现在系统已经配置好了x509认证方式,只要将生成的客户端证书user.p12导入到浏览器中,直接访问系统就会发现已经用user用户登录到系统中了。 如果使用IE浏览器,可以直接双击user.p12进行导入。 如果使用FireFox浏览器,需要点击浏览器工具条上的“工具”->“选项”->“高级”->“查看证书”,在“证书管理器”中导入user.p12。 实例在ch115。 第 24 章 使用NTLM登录(无法成功登陆) NTLM是NT Lan Manager,即Window NT局域网管理器。它会以本地系统当前用户尝试访问远程主机,基本工作流程请去网上搜索。 警告 这章会演示如何在Spring Security中使用NTLM,但是因为不了解如何配置NTLM所以实例虽然可以运行,但是一直无法登陆。 如果有谁知道如何配置NTLM可以在ch116成功进行登陆,请联系xyz20003@gmail.com,感激不禁。 NTLM验证允许Windows用户使用当前登录系统的身份进行认证,当前用户应该是登陆在一个域(domain)上,他的身份是可以自动通过浏览器传递给服务器的。它是一种单点登录的策略,系统可以通过NTLM重用登录到Windows系统中的用户凭证,不用再次要求用户输入密码进行认证。 不过,它只能用在IE中。使用Firefox时,你会被要求输入用户名和密码,这时可以使用下面的配置启用NTLM。 · 在Firefox的地址栏中输入“about:config”。 · 这时可以看到Firefox的所有配置,现在找到“network.automatic-ntlm-auth.trusted-uris”。 · 输入主机名称,比如“host1.domain.com, host2.domain.com”,也可以直接输入“.domain.com”。 现在我们开始在Spring Security中配置NTLM。 首先在pom.xml中添加依赖: org.springframework.security spring-security-ntlm 2.0.5.RELEASE 然后修改xml文件,添加NTLM的过滤器和入口点。 我们还需要创建一个UserDetailsAuthenticationProvider,可以直接通过username从UserDetailsService中获得用户信息与相应的权限。 package com.family168.springsecuritybook.ch116; import org.springframework.dao.DataAccessException; import org.springframework.security.AuthenticationException; import org.springframework.security.AuthenticationServiceException; import org.springframework.security.providers.UsernamePasswordAuthenticationToken; import org.springframework.security.providers.dao.AbstractUserDetailsAuthenticationProvider; import org.springframework.security.userdetails.UserDetails; import org.springframework.security.userdetails.UserDetailsService; public class UserDetailsAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { private UserDetailsService userDetailsService; protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { UserDetails loadedUser; try { loadedUser = this.getUserDetailsService().loadUserByUsername(username); } catch (DataAccessException repositoryProblem) { throw new AuthenticationServiceException(repositoryProblem.getMessage(), repositoryProblem); } if (loadedUser == null) throw new AuthenticationServiceException("User cannot be null"); return loadedUser; } protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException{ } public UserDetailsService getUserDetailsService() { return userDetailsService; } public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } } 现在的问题就是如何在NTLM中配置了,如何配置才能让ch116正常登陆。 实例在ch116。 第 25 章 使用JAAS机制 可以在Spring Security中使用JAAS机制进行用户的身份认证。 JAAS即Java Authentication and Authorization Service,它是JDK自带的一套专门用于处理用户认证和授权的标准API,Spring Security中可以使用API作为AuthenticationProvider处理用户认证与授权。 配置文件中,我们使用JaasAuthenticationProvider作为AuthenticationProvider。 注意不能在http标签中使用auto-config="true"或是在http标签中包含rememberMe,因为rememberMe需要引用userDetailsService,而在使用JaasAuthenticationProvider时,用户数据校验是交由LoginModule处理的,不会使用userDetailsService,所以rememberMe会抛出异常。 我们将JAAS所需的配置文件放在/WEB-INF/login.config。 JAASTest { com.family168.springsecuritybook.ch117.LoginModuleImpl required; }; 并在配置文件中指明使用JAASTest作为登陆上下文。 现在要创建LoginModuleImpl用来处理用户登录。 package com.family168.springsecuritybook.ch117; import java.security.Principal; import java.util.Map; import javax.security.auth.Subject; import javax.security.auth.callback.Callback; import javax.security.auth.callback.CallbackHandler; import javax.security.auth.callback.NameCallback; import javax.security.auth.callback.PasswordCallback; import javax.security.auth.callback.TextInputCallback; import javax.security.auth.login.LoginException; import javax.security.auth.spi.LoginModule; public class LoginModuleImpl implements LoginModule { private String password; private String user; private Subject subject; public boolean abort() throws LoginException { return true; } public boolean commit() throws LoginException { return true; } public void initialize(Subject subject, CallbackHandler callbackHandler, Map sharedState, Map options) { this.subject = subject; try { TextInputCallback textCallback = new TextInputCallback("prompt"); NameCallback nameCallback = new NameCallback("prompt"); PasswordCallback passwordCallback = new PasswordCallback("prompt", false); callbackHandler.handle(new Callback[] {textCallback, nameCallback, passwordCallback}); password = new String(passwordCallback.getPassword()); user = nameCallback.getName(); } catch (Exception e) { throw new RuntimeException(e); } } public boolean login() throws LoginException { if (!user.equals("user")) { throw new LoginException("Bad User"); } if (!password.equals("user")) { throw new LoginException("Bad Password"); } subject.getPrincipals().add(new Principal() { public String getName() { return "TEST_PRINCIPAL"; } }); subject.getPrincipals().add(new Principal() { public String getName() { return "NULL_PRINCIPAL"; } }); return true; } public boolean logout() throws LoginException { return true; } } 当用户登录成功时,会通过authorityGranters为权限主体授权,这一步也要自己实现AuthorityGranter接口。 package com.family168.springsecuritybook.ch117; import java.security.Principal; import java.util.HashSet; import java.util.Set; import org.springframework.security.providers.jaas.AuthorityGranter; public class AuthorityGranterImpl implements AuthorityGranter { public Set grant(Principal principal) { Set rtnSet = new HashSet(); if (principal.getName().equals("TEST_PRINCIPAL")) { rtnSet.add("ROLE_USER"); rtnSet.add("ROLE_ADMIN"); } return rtnSet; } } 至此,JAAS与Spring Security结合进行认证授权的功能已经完成,每一步都要件功能写死在代码里,让人感觉很不舒服。 实例在ch117。 26 章 使用HttpInvoker Spring Security提供了AuthenticationSimpleHttpInvokerRequestExecutor,可以在调用HttpInvoker时,自动根据当前权限主体生成basic认证所需的http请求头,以此来通过远程服务器的认证,从而访问httpInvoker暴露的远程资源。 只需要为HttpInvokerProxyFactoryBean配置httpInvokerRequestExecutor属性。 实例在ch118。 第 27 章 使用rmi Spring Security提供了ContextPropagatingRemoteInvocationFactory,可以在调用rmi时,将当前SecurityContext当做rmi的一部分传递到远程服务器,并使用这个SecurityContext在远程服务器中进行权限校验,这也就是RunAsManager的作用所在,当一个用户访问一个方法时,需要自动调用远程服务,这个远程服务中需要一个角色才可以通过校验,为了让调用此方法的用户都拥有调用远程资源的权限,才使用了RunAsManager为所有访问该方法的用户自动添加一个角色。 实际使用时,只需要为RmiProxyFactoryBean配置remoteInvocationFactory属性。 实例在ch119。 第 28 章 控制portal的权限 实际是只是为portlet添加权限拦截器,它会为portlet进行Pre-Auth式认证,就是说它会从当前SecurityContext中取出权限实体,用来进行portlet的校验。 在portlet对应的配置文件中配置如下拦截器,用于校验对应portlet的权限。 实例在ch120。 第 29 章 保存登录之前的请求 经常会碰到一种情况,用户花费大量时间编辑信息,但是session超时失效导致用户自动退出系统,安全过滤器会强制用户再次登录,但这也会使用户提交的信息全部丢失。 为了解决这个问题,Spring Security提供了一种称作SavedRequest功能,可以在未登录用户访问资源时,将用户请求保存起来,当用户登录成功之后SecurityContextHolderAwareRequestFilter会使用之前保存的请求,结合当前用户的请求生成一个新的请求对象,而这个请求对象中就保存了用户登录之前提交的信息。 SavedRequest功能默认就会被Spring Security启用,不需任何配置就可以重用登陆之前请求提交的数据。 如果希望尝试SavedRequest的功能,可以运行ch121中的实例。 进入系统后会显示一个表单,可以在表单中填写信息,然后进行提交。提交的目标页面是user.jsp。 图 29.1. 未登录用户提交信息 但是因为用户尚未登录,所以请求会被转发到登陆页面,当我们登录成功后,系统会自动跳转到用户登录之前请求的页面,user.jsp。 图 29.2. 进入登陆页面 因为我们使用了SavedRequest功能,所以现在就能在user.jsp看到我们登陆之前提交的信息。 图 29.3. 使用登录之前提交的信息 如果希望禁用SavedRequet这一功能,只需要在http标签中设置servlet-api-provision参数。 实例在ch121。 部分 III. 内部机制篇 Spring Security使用AOP对方法调用进行权限控制,这部分内容基本都是来自于Spring提供的AOP功能,Spring Security进行了自己的封装,我们可以使用声明和编程两种方式进行权限管理。 第 30 章 保护方法调用 这里有三种方式可以选择: 30.1. 控制全局范围的方法权限 使用global-method-security和protect-point标签来管理全局范围的方法权限。 为了在spring中使用AOP,我们要为项目添加几个依赖库。 cglib cglib-nodep 2.1_3 org.aspectj aspectjrt 1.6.5 org.aspectj aspectjweaver 1.6.5 首先来看看我们将要保护的java类。 package com.family168.springsecuritybook.ch201; public class MessageServiceImpl implements MessageService { public String adminMessage() { return "admin message"; } public String adminDate() { return "admin " + System.currentTimeMillis(); } public String userMessage() { return "user message"; } public String userDate() { return "user " + System.currentTimeMillis(); } } 这里使用的是spring-2.0中的aop语法,对MessageService中所有以admin开头的方法进行权限控制,限制这些方法只能由ROLE_ADMIN调用。 现在只有拥有ROLE_ADMIN权限的用户才能调用MessageService中以admin开头的方法了,当我们以user/user登陆系统时,尝试调用MessageService类的adminMessage()会跑出一个“访问被拒绝”的异常。 30.2. 控制某个bean内的方法权限 在bean中嵌入intercept-methods和protect标签。 这需要改造配置文件。 现在messageService中的userMessage()方法只允许拥有ROLE_ADMIN权限的用户才能调用了。 使用intercept-methods面临着几个问题 首先,intercept-methods只能使用jdk14的方式拦截实现了接口的类,而不能用cglib直接拦截无接口的类。 其次,intercept-methods和global-method-security一起使用,同时使用时,global-method-security一切正常,intercept-methods则会完全不起作用。 30.3. 使用annotation控制方法权限 借助jdk5以后支持的annotation,我们直接在代码中设置某一方法的调用权限。 现在有两种选择,使用Spring Security提供的Secured注解,或者使用jsr250规范中定义的注解。 30.3.1. 使用Secured 首先修改global-method-security中的配置,添加支持annotation的参数。 然后添加依赖包。 org.springframework.security spring-security-core-tiger 2.0.5.RELEASE 现在我们随便在java代码中添加注解了。 package com.family168.springsecuritybook.ch201; import org.springframework.security.annotation.Secured; public class MessageServiceImpl implements MessageService { @Secured({"ROLE_ADMIN", "ROLE_USER"}) public String userMessage() { return "user message"; } } 在Secured中设置了ROLE_ADMIN和ROLE_USER两个权限,只要当前用户拥有其中任意一个权限都可以调用这个方法。 30.3.2. 使用jsr250 首先还是要修改配置文件。 然后添加依赖包。 javax.annotation jsr250-api 1.0 现在可以在代码中使用jsr250中的注解了。 package com.family168.springsecuritybook.ch201; import javax.annotation.security.DenyAll; import javax.annotation.security.PermitAll; import javax.annotation.security.RolesAllowed; public class MessageServiceImpl implements MessageService { @RolesAllowed({"ROLE_ADMIN", "ROLE_USER"}) public String userMessage() { return "user message"; } @DenyAll public String userMessage2() { return "user message"; } @PermitAll public String userMessage2() { return "user message"; } } RolesAllowed与前面的Secured功能相同,用户只要满足其中定义的权限之一就可以调用方法。 DenyAll拒绝所有的用户调用方法。 PermitAll允许所有的用户调用方法。 从实际使用上来讲,jsr250里多出来的DenyAll和PermitAll纯属浪费,谁会定义谁也不能调用的方法呢?实际上,要是annotation支持布尔操作就好了,比如逻辑并,逻辑或,逻辑否之类的。 还有jsr250中未被支持的RunAs注解,如果能利用起来估计更有趣。 实例在ch201。 第 31 章 权限管理的基本概念 31.1. 认证与验证 Spring Security作为权限管理框架,其内部机制可分为两大部分,其一是认证授权auhorization,其二是权限校验authentication。 认证授权authorization是指,根据用户提供的身份凭证,生成权限实体,并为之授予相应的权限。 权限校验authentication是指,用户请求访问被保护资源时,将被保护资源所需的权限和用户权限实体所拥护的权限二者进行比对,如果校验通过则用户可以访问被保护资源,否则拒绝访问。 我们之前讲过的form-login,http-basic, digest都属于认证授权authorization部分的概念,用户可以通过这些机制登录到系统中,系统会为用户生成权限主体,并授予相应的权限。 与之相对的,FilterSecurityInterceptor,Method保护,taglib,@Secured都属于权限校验authentication,无论是对URL的请求,对方法的调用,页面信息的显示,都要求用户拥有相应的权限才能访问,否则请求即被拒绝。 31.2. SecurityContext安全上下文 为使所有的组件都可以通过同一方式访问当前的权限实体,Spring Security特别提供了SecurityContext作为安全上下文,可以直接通过SecurityContextHolder获得当前线程中的SecurityContext。 SecurityContext securityContext = SecurityContextHolder.getContext(); 默认情况下,SecurityContext的实现基于ThreadLocal,系统会在每次用户请求时将SecurityContext与当前Thread进行绑定,这在web系统中是很常用的使用方式,服务器维护的线程池允许多个用户同时并发访问系统,而ThreadLocal可以保证隔离不同Thread之间的信息。 当时对于单机应用来说,因为只有一个人使用,并不存在并发的情况,所以完全可以让所有Thread都共享同一个SecurityContext,因此Spring Security为我们提供了不同的策略模式,我们可以通过设置系统变量的方式选择希望使用的策略类。 java -Dspring.security.strategy=MODE_GLOBAL com.family168.springsecuritybook.Main 也可以调用SecurityContextHolder的setStrategyName()方法来修改系统使用的策略。 SecurityContextHolder.setStrategyName("MODE_GLOBAL"); 31.3. Authentication验证对象 SecurityContext中保存着实现了Authentication接口的对象,如果用户尚未通过认证,那么SecurityContext.getAuthenticaiton()方法就会返回null。 可以使用Authentication接口中定义的几个方法,获得当前权限实体的信息。 public interface Authentication extends Principal, Serializable { GrantedAuthority[] getAuthorities(); Object getCredentials(); Object getDetails(); Object getPrincipal(); boolean isAuthenticated(); void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException; } 默认情况下,会在某一个进行认证的过滤器中生成一个UsernamePasswordAuthenticationToken实例,并将此实例放到SecurityContext中。 获得权限主体拥有的权限。 权限实体拥有的权限,GrantedAuthority接口内只有一个方法getAuthority(),它的返回值是一个字符串,这个字符串用来标识系统中的某一权限。用户认证后权限实体将拥有一个保存了一系列GrantedAuthority对象的数组,之后可以用于进行验证用户是否可以访问系统中被保护资源。 获得权限主体的凭证,此凭证应该可以唯一标示权限主体。 默认情况下,凭证是用户的登录密码。 获得验证请求有关的附加信息。 默认情况下,附加信息是WebAuthenticationDetails的一个实例,其中保存了客户端ip和sessionid。 获得权限主体。 默认情况下,权限主体是一个实现了UserDetails接口的对象。 第 32 章 Voter表决者 32.1. Voter表决者 实际上并没有翻译的字面含义那么有血有肉,实际上就是一些条件,判断权限的时候,这些条件有三个状态。弃权,通过,禁止。最后通过你在xml里配置的策略来决定到底是不是让你访问这个需要验证的对象。 Spring Security提供的策略有三个 · UnanimousBased.java 只要有一个Voter不能完全通过权限要求,就禁止访问。这个太可怕了,我今天晚上就载在它上面了。就因为我给所有的资源设置了两个角色,但当前的用户只拥有其中一个角色,就导致这个用户因为权限不够,所以无法继续访问资源了。简直无法理喻啊。 · AffirmativeBased.java只要有一个Voter可以通过权限要求,就可以访问。这里应该是一个最小通过,就是说至少满足里其中一个条件就可以通过了。 · ConsensusBased.java只要通过的Voter比禁止的Voter数目多就可以访问了。嘿嘿。 最后我当然选择AffirmativeBased.java,这样,我给一个资源配置几个角色,用户只要满足其中一个角色就可以访问啦。这样更正常一些啊。 默认提供的Voter继承关系如下。 AccessDecisionVoter AbstractAclVoter AclEntryVoter(acls) BasicAclEntryVoter LabelBasedAclVoter RoleVoter RoleHierarchyVoter AuthenticatedVoter Jsr250Voter(tiger) 32.2. RoleVoter 默认角色名称都是以ROLE_开头 稍微注意一下,默认角色名称都要以ROLE_开头,否则不会被计入权限控制,如果需要修改,就在xml里配个什么前缀的。可以用过配置roleVoter的rolePrefix来改变这个前缀。 32.3. AuthenticatedVoter AuthenticatedVoter用于判断ConfigAttribute上是否拥有IS_AUTHENTICATED_FULLY,IS_AUTHENTICATED_REMEMBERED或IS_AUTHENTICATED_ANONYMOUSLY之类的配置。 如果配置为IS_AUTHENTICATED_FULLY,那么只有AuthenticationTrustResolver的isAnonymous()和isRememberMe()都返回false时才能通过验证。 如果配置为IS_AUTHENTICATED_REMEMBERED,那么会在AuthenticationTrustResolver的isAnonymous()返回false时通过验证。 如果配置为IS_AUTHENTICATED_ANONYMOUSLY,就可以在AuthenticationTrustResolver的isAnonymous()和isRememberMe()两个方法返回任意值时都可以通过验证。 32.4. AbstractAclVoter BasicAclEntryVoter, LabelBasedAclVoter, AclEntryVoter用来在处理ACL中的权限控制。在Spring Security中的ACL 都是基于特定POJO类的,每个AclVoter都会分配给一个特定的POJO类,并用来专门控制方法中对这个POJO类操作的权限。实际上所有AclVoter都是用于处理方法调用的,它会检测方法调用时传递的每个参数,当某个参数的类型与AclVoter对应的POJO类一致时,就会采用配置好的元数据进行权限校验。 上一页 第 33 章 拦截器 无论是Filter,MethodInterceptor,ACL都要用到AOP,实际上都是拦截器的概念,其中要用到AbstractSecurityInterceptor总拦截器,AfterInvocationManager后置拦截,authenticationManager验证管理器,可能还要用上RunAsManager。 关于RunAsManager,官方文档给出的解释是,在HttpInvoker或者Web Service的情况下,当前用户的一些身份要转换成其他身份,这时就是用RunAsManager,默认将以RUN_AS_开头的权限名,改变成 ROLE_RUN_AS_开头的权限名,然后重新赋予当前认证主体是用。现在的问题是不清楚具体使用在什么场景。 33.1. 权限配置数据源 处于继承树顶端的AbstractSecurityInterceptor有三个实现类: · FilterSecurityInterceptor,负责处理FilterInvocation,实现对URL资源的拦截。 · MethodSecurityInterceptor,负责处理MethodInvocation,实现对方法调用的拦截。 · AspectJSecurityInterceptor,负责处理JoinPoint,主要也是用于对方法调用的拦截。 为了限制用户访问被保护资源,Spring Security提供了一套元数据,用于定义被保护资源的访问权限,这套元数据主要体现为ConfigAttribute和ConfigAttributeDefinition。每个ConfigAttribute中只包含一个字符串,而一个ConfigAttributeDefinition中可以包含多个ConfigAttribute。对于系统来说,每个被保护资源都将对应一个ConfigAttributeDefinition,这个ConfigAttributeDefinition中包含的多个ConfigAttribute就是访问该资源所需的权限。 实际应用中,ConfigAttributeDefinition会保存在ObjectDefinitionSource中,这是一个主要接口,FilterSecurityInterceptor所需的DefaultFilterInvocationDefinitionSource和MethodSecurityInterceptor所需的MethodDefinitionAttributes都实现了这个接口。ObjectDefinitionSource可以看做是Spring Security中权限配置的源头,框架内部所有的验证组件都是从ObjectDefintionSource中获得数据,来对被保护资源进行权限控制的。 为了从xml中将用户配置的访问权限转换成ObjectDefinitionSource类型的对象,Spring Security专门扩展了Spring中提供的PropertyEditor实现了ConfigAttributeEditor,它可以把以逗号分隔的一系列字符串转换成包含多个ConfigAttribute的ConfigAttributeDefintion对象。 "ROLE_ADMIN,ROLE_USER" ↓ ConfigAttributeDefinition ConfigAttribute["ROLE_ADMIN"] ConfigAttribute["ROLE_USER"] 对于FilterSecurityInterceptor来说,最终生成的就是一个包含了url pattern和ConfigAttributeConfiguration的ObjectDefinitionSource。 ↓ ConfigAttributeDefinition "/admin.jsp" → ConfigAttribute["ROLE_ADMIN"] ConfigAttribute["ROLE_USER"] 换而言之,无论我们将权限配置的原始数据保存在什么地方,只要最终可以将其转换为ObjectDefintionSource就可以提供给验证组件进行调用,实现权限控制。 33.2. 权限管理器 AbstractSecurityInterceptor中将几个权限管理器组合应用,AuthenticationManager, AccessDecisionManager, AfterInvocationManager和RunAsManager。 AuthenticationManager用来对用户请求进行认证工作,默认情况下,我们使用的实现类为NamespaceAuthenticationManager。它内部将包含一个AuthenticationProvider队列,在实际进行权限校验的时候顺序执行这个队列实现对用户的认证功能。AuthenticationProvider中最常见的实现是DaoAuthenticationProvider,它可以根据用户的username和password对用户有效性进行验证,如果通过校验就会从userDetailsService中获取用户信息,并为用户授予对应的权限。 AccessDecisionManager用于控制资源的访问权限,它下面有三个实现类可以选择AffirmativeBased, UnanimousBased和ConsensusBased,分别对应,一票通过,一票否决,多数通过。每个AccessDecisionManager内部拥有多个Voter,每个Voter会进行表决,表决的结果有ACCESS_GRANTED赞成, ACCESS_DENIED反对, ACCESS_ABSTAIN弃权三种。AccessDecisionManager会根据最终投票的结果,结合实现类的策略判断用户是否可以访问当前资源。 33.3. 后置调用管理器 AfterInvocationManager用来在方法调用完成后,根据用户的权限,对方法返回的结果进行筛选,它主要是用在ACL中的。 AfterInvocationManager有两个实现类。BasicAclEntryAfterInvocationCollectionFilteringProvider用于过滤返回结果为集合的方法,BasicAclEntryAfterInvocationProvider用于控制返回结果为对象的方法。 33.4. 临时分配额外权限 我们可以在某一方法调用过程中,使用RunAsManager为用户临时分配额外的权限。据说这个功能是为了在方法内部远程调用被保护资源而实现的,为实现这一功能,我们首先要在AbstractMethodInterceptor中设置RunAsManagerImpl,并且要在ObjectDefintionSource中配置RUN_AS_开头的权限,这样,当用户访问这个方法时,就会自动将SecurityContext中保存的Authenticaton对象替换为RUN_AS对象,并在其中附加额外的权限。 现在还不能在2.x版本的命名空间中调用RunAsManager,3.0.0.M1中提供了如下方法。 第 34 章 用户信息 34.1. UserDetails Spring Security中的UserDetails被作为一个通用的权限主体,凡是涉及到username和password的情况,都会使用到UserDetails和它对应的服务。 常用的服务有从内存中读取用户信息的InMemoryDaoImpl和用数据库中读取用户信息的JdbcDaoImp。它们都实现了UserDetailsService,因此都可以使用loadUserByUsername()方法获得对应用户的信息。 如果使用了LDAP,还会接触到LdapUserDetailsService,它用来从LDAP中获取用户信息。 在org.springframework.security.userdetails包下还包含一个check目录,它主要用来校验用户是否过期,是否被锁定,是否被禁用。 还可以看到一个hierarchicalroles,它的作用是处理角色继承关系,如果希望使用角色继承策略,需要将原始的UserDetailsService通过UserDetailsServiceWrapper进行一下封装,从而获得由UserDetailsWrapper封装的UserDetails,以此来实现角色继承机制。 34.2. 使用角色继承 在Spring Security中,我们可以指定角色间的继承关系,这样可以重用角色权限,减少配置的代码量,让权限配置整体上显得更清晰。 为了使用角色继承功能,我们需要对原有的配置文件进行一些修改。 我们将原有的user-service单独抽离出来,在userDetailsService的基础上生成一个userDetailsServiceWrapper,这个wrapper的作用就是在原有的user-service的基础上启用角色继承功能。 我们使用RoleHierarchyImpl为userDetailsServiceWrapper配置了角色继承的策略,ROLE_ADMIN > ROLE_USER表示ROLE_ADMIN将继承ROLE_USER所有用的所有角色,只要是允许ROLE_USER访问的资源,ROLE_ADMIN也都有权限进行访问。这样我们在user-service中的配置就可以从ROLE_ADMIN,ROLE_USER简化为ROLE_ADMIN了,而intercept-url中的配置也可以从ROLE_ADMIN,ROLE_USER改为ROLE_USER了。 如果希望配置更多继承关系,可以使用换行进行分隔,比如: ROLE_A > ROLE_B ROLE_B > ROLE_AUTHENTICATED ROLE_AUTHENTICATED > ROLE_UNAUTHENTICATED 实例在ch205。 34.3. 为ACL添加角色继承 目前,直至Spring Security-3.0.0.M1都不支持在acl中使用RoleHierarchy,不过在官网的jira上有人提交了一个patch,如果情况顺利的话,这个patch应该在Spring Security-3.0.0.M2中被应用到svn中,我们就可以为acl实现角色继承了。 如果希望在Spring Security-2.x中在acl部分实现角色继承,需要进行如下配置。 首先根据jira上的patch自己创建一个SidRoleHierarchyRetrievalStrategyImpl.java。 /* Copyright 2008 Thomas Champagne * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.springframework.security.acls.sid; import java.util.List; import org.springframework.security.Authentication; import org.springframework.security.GrantedAuthority; import org.springframework.security.userdetails.hierarchicalroles.RoleHierarchy; import org.springframework.util.Assert; /** * Extended SidRetrievalStrategyImpl which uses a {@link RoleHierarchy} definition to determine the * roles allocated to the current user. * @author Thomas Champagne */ public class SidRoleHierarchyRetrievalStrategyImpl extends SidRetrievalStrategyImpl { private RoleHierarchy roleHierarchy = null; public SidRoleHierarchyRetrievalStrategyImpl(RoleHierarchy roleHierarchy) { Assert.notNull(roleHierarchy, "RoleHierarchy must not be null"); this.roleHierarchy = roleHierarchy; } /** * Calls the RoleHierarchy to obtain the complete set of user authorities. */ GrantedAuthority[] extractAuthorities(Authentication authentication) { return roleHierarchy.getReachableGrantedAuthorities(authentication.getAuthorities()); } public Sid[] getSids(Authentication authentication) { GrantedAuthority[] authorities = this.extractAuthorities(authentication); Sid[] sids = new Sid[authorities.length + 1]; sids[0] = new PrincipalSid(authentication); for (int i = 1; i <= authorities.length; i++) { sids[i] = new GrantedAuthoritySid(authorities[i - 1]); } return sids; } } 然后在acl的配置文件中配置bean,并在AclEntryVoter,AclEntryAfterInvocationProvider和AclEntryAfterInvocationCollectionFilteringProvider中替换默认的SidRetrievalStrategy。 这样就在acl中添加了对角色继承的支持。 34.4. PasswordEncoder和SaltValue 默认提供的PasswordEncoder包含plaintext, sha, sha-256, md5, md4, {sha}, {ssha}。其中{sha}和{ssha}是专门为ldap准备的,plaintext意味着不对密码进行加密,如果我们不设置PasswordEncoder,默认就会使用它。 SaltValue是为了让密码加密更加安全,它有两种策略可以选择。user-property, system-wide分别对应着ReflectionSaltSource和SystemWideSaltSource,它们的区别是ReflectionSaltSource会使用反射,从用户的Principal对象汇总取出一个对应的属性来作为盐值,而SystemWideSaltSource会为所有用户都设置相同的盐值。 使用了PasswordEncoder和SaltValue的结果就是数据库中的密码变得难以辨认了,这就要注意在添加用户时要使用相同的策略对密码进行加密,这才能保证新用户可以正常登陆。 第 35 章 集成jcaptcha 使用jcaptcha实现彩色验证码,这是一个被spring security 2放弃的组件,据说本来维护acegi版本的作者不见了,所以组件就放到了sandbox中,最终被废弃了。 首先在pom.xml中添加依赖。 com.octo.captcha jcaptcha 1.0 commons-lang commons-lang 2.4 然后,将jcaptcha集成相关的代码复制到src/main/java目录下。 下一步,修改配置文件,添加jcaptcha的过滤器与provider,代码太多,请参考示例中的实际配置。 最终自定义登录页面,显示效果如下所示: 图 35.1. jcaptcha 实例在ch206。 第 36 章 动态资源管理 之前在第 5 章 使用数据库管理资源中,我们简要讨论过使用数据库管理资源,为了使手册开始的部分保持简洁,我们没有再深入讨论这个话题,包括实例中存在的一些问题也都没有解决,这一章中,我们会尝试进行更深层次的讨论。 36.1. 基本知识 对应的数据库结构与ER图,可以参考第 5 章 使用数据库管理资源。 拦截器与所需的权限配置数据格式,可以参考第 33 章 拦截器。 所有,我们需要做的就是把数据库中的数据读取出来,组装成拦截器所需的格式,然后把权限配置数据放到拦截器里。 36.2. 读取资源 为了区分URL和METHOD,我们在resc表中使用res_type字段来区分这两种不同的被保护资源。 res_type="URL"对应将在FilterSecurityInterceptor中使用的被保护网址。 INSERT INTO RESC VALUES(1,'','URL','/admin.jsp',1,'') 这里将/admin.jsp作为一个网址进行保护,随后它将被设置到FilterSecurityInterceptor中。 res_type="METHOD"对应将在MethodSecurityInterceptor中使用的被保护方法。 INSERT INFO RESC VALUES(3,'','METHOD','com.family168.springsecuritybook.ch207.MessageService.adminMessage',3,''); 这里将com.family168.springsecuriytbook.ch207.Message的adminMessage()方法设置为被保护资源,随后它将被设置到MethodSecurityInterceptor中。 我们使用如下sql语句从数据库中分别读取被保护的url和method信息。 读取被保护url信息。 select re.res_string,r.name from role r join resc_role rr on r.id=rr.role_id join resc re on re.id=rr.resc_id where re.res_type='URL' order by re.priority 读取被保护method信息。 select re.res_string,r.name from role r join resc_role rr on r.id=rr.role_id join resc re on re.id=rr.resc_id where re.res_type='METHOD' order by re.priority 为了实现资源的统一配置,我们创建了名为ResourceDetailsMonitor的类用来管理数据库中的被保护资源信息,它负责从数据库中读取原始信息,并转换成FilterSecurityInterceptor和MethodInterceptor所需的数据格式。 36.3. URL资源扩展点 为了动态设置FilterSecurityInterceptor中的资源配置,ResouceDetailsMonitor中直接将组装后的FilterInvocationDefinitionSource使用setObjectDefinitionSource()方法设置到FilterSecurityInterceptor中。 FilterInvocationDefinitionSource source = resourceDetailsBuilder .createUrlSource(queryUrl, getUrlMatcher()); filterSecurityInterceptor.setObjectDefinitionSource(source); 之后,FilterSecurityInterceptor就会根据我们设置的资源信息控制用户可以访问哪些资源。 36.4. METHOD资源扩展点 MethodSecurityInterceptor的情况有些复杂,因为涉及到spring中aop的pointcut部分特性,所以直接为MethodSecurityInterceptor设置objectDefinitionSource是不会起作用的。 我们需要获取delegatingMethodDefinitionSource,将数据库中读取的资源信息设置到它里面才能使MethodSecurityInterceptor和动态生成的pointcut都是用我们最新的资源信息。 MethodDefinitionSource source = resourceDetailsBuilder .createMethodSource(queryMethod); List sources = new ArrayList(); sources.add(source); delegatingMethodDefinitionSource.setMethodDefinitionSources(sources); 因为ACL实际上也是借助于MethodSecurityInterceptor来实现的,所以可以将ACL_READ和AFTER_ACL_READ配置在res_type="METHOD"的资源中。 实例在ch207中。 第 37 章 扩展UserDetails 如果希望扩展登录时加载的用户信息,最简单直接的办法就是实现UserDetails接口,定义一个包含所有业务数据的对象。我们下面演示如何将用户邮箱加入UserDetails中。 37.1. 实现UserDetails接口 UserDetails接口中总共声明了六个方法: public interface UserDetails extends Serializable { GrantedAuthority[] getAuthorities(); String getPassword(); String getUsername(); boolean isAccountNonExpired(); boolean isAccountNonLocked(); boolean isCredentialsNonExpired(); boolean isEnabled(); } 用户拥有的权限 用户名 密码 用户账号是否过期 用户账号是否被锁定 用户密码是否过期 用户是否可用 我们的任务就是实现这六个接口,同时添加一个getEmail()方法,用以获得用户的邮箱地址。 最初我们的打算是直接继承Spring Security中默认提供的实现类User,但是User为了避免用户信息被外部程序篡改,被设计为只能通过构造方法来为内部数据赋值,没有提供setter方法对其中数据进行修改,因此为了之后演示的方便,我们仿照User类自行实现了一个BaseUserDetails类,在BaseUserDetails中所有属性都被定义为protected,可以暴露给子类进行操作。 在BaseUserDetails的基础上,我们实现了UserInfo类,在它里面添加有关email的属性和方法。 package com.family168.springsecuritybook.ch208; import org.springframework.security.GrantedAuthority; public class UserInfo extends BaseUserDetails { private static final long serialVersionUID = 1L; private String email; public UserInfo(String username, String password, boolean enabled, GrantedAuthority[] authorities) throws IllegalArgumentException { super(username, password, enabled, authorities); } public String getEmail() { return this.email; } public void setEmail(String email) { this.email = email; } } 37.2. 实现UserDetailsService接口 为了将UserInfo提供给权限系统,我们还需要实现自定义的UserDetailsService,这个接口只包含一个方法: public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException; } 实际运行中,系统会通过这个方法获得登录用户的信息。 下面我们直接实现UserDetailsService接口,在其中创建UserInfo的对象。 public class UserInfoService implements UserDetailsService { private Map userMap = null; public UserInfoService() { userMap = new HashMap(); UserInfo userInfo = null; userInfo = new UserInfo("user", "user", true, new GrantedAuthority[]{ new GrantedAuthorityImpl("ROLE_USER") }); userInfo.setEmail("user@family168.com"); userMap.put("user", userInfo); userInfo = new UserInfo("admin", "admin", true, new GrantedAuthority[]{ new GrantedAuthorityImpl("ROLE_ADMIN"), new GrantedAuthorityImpl("ROLE_USER") }); userInfo.setEmail("admin@family168.com"); userMap.put("admin", userInfo); } public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException { return userMap.get(username); } } 37.3. 修改配置文件 将UserInfoService添加到配置文件中: 定义userDetailsService之后,然后使用user-service-ref为authentication-provider设置对UserDetailsService的引用,这样在系统中就会从我们自定义的UserInfoService中获取用户信息了。 37.4. 测试运行 修改过配置文件后,在ch208中启动mvn,还是通过登录页面进入系统,在登录成功页面中就可以看到用户对应的邮箱地址了。 图 37.1. 显示邮箱地址信息 这时保存在SecurityContext中的Principal已经变为了UserInfo类型的对象,我们可以直接使用taglib获得启动的邮件信息。 email : 如果希望获得UserInfo对象,可以使用如下代码: UserInfo userInfo = (UserInfo) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); 实例在ch208中。 第 38 章 锁定用户 这里我们通过一个常见的功能需求来演示如何锁定一个用户的账号。 下面我们需要实现这样一个功能,当一个用户输错三次密码后,就锁定这个账号。 首先我们要注意到,虽然UserDetails中提供了isAccountNonLocked()方法,框架中也提供了LockedException,但是默认提供的User实现类不太容易实现对用户锁定的操作,为此,我们需要借用???中定义的UserInfo来实现对用户的锁定。 为了监听用户输入错误密码的事件,我们需要自定义一个事件监听器LockUserListener,它的主体代码如下所示: public void onApplicationEvent(ApplicationEvent event) { if (event instanceof AuthenticationFailureBadCredentialsEvent) { AuthenticationFailureBadCredentialsEvent authEvent = (AuthenticationFailureBadCredentialsEvent) event; Authentication authentication = (Authentication) authEvent.getSource(); String username = (String) authentication.getPrincipal(); addCount(username); } if (event instanceof AuthenticationSuccessEvent) { AuthenticationSuccessEvent authEvent = (AuthenticationSuccessEvent) event; Authentication authentication = (Authentication) authEvent.getSource(); UserInfo userInfo = (UserInfo) authentication.getPrincipal(); String username = userInfo.getUsername(); cleanCount(username); } } 其中,AuthenticationFailureBadCredentialsEvent事件表示用户输入了错误的密码,AuthenticationSuccessEvent表示用户登录成功。 将LockUserListener添加到配置文件中即可监听权限校验的事件。 在获得AuthenticationFailureBadCredentialsEvent事件,也即用户输入错密码时,我们首先要获得对应的用户名,然后将用户名对应的输入密码错误次数加一。当获得AuthenticationSuccessEvent事件时,说明用户已经成功登陆了,这时我们要把用户之前输入错误的密码次数清零,让他再次登录时还可以享有三次错误的机会。 这里为了演示方便,直接将用户输入密码错误的次数记录在ServletContext中,代码如下所示: protected void addCount(String username) { Map lockUserMap = getLockUserMap(); Integer count = lockUserMap.get(username); if (count == null) { lockUserMap.put(username, Integer.valueOf(1)); } else { int resultCount = count.intValue() + 1; if (resultCount > 3) { UserInfo userInfo = (UserInfo) userInfoService.loadUserByUsername(username); userInfo.lockAccount(); } else { lockUserMap.put(username, Integer.valueOf(resultCount)); } } } 当用户输错密码超过三次时,就会从UserInfoService中取出对应的UserInfo对象,使用lockAccount()方法将该用户账号锁定,之后用户就会在登录上看到对应的错误信息。 图 38.1. 用户已被锁定 之后用户再怎么尝试也无法登陆到系统中了。 实例在ch209中。 用户过期与密码过期的处理方法与锁定用户类似,等以后有机会再详细介绍。 第 39 章 设置过滤器链 对于不是从acegi升级到spring security的同志们,用多了命名空间这种配置方式,肯定在抱怨它的扩展性不够。现在我们就来展示一下在acegi中屡遭诟病的自定义过滤器链配置方式。 警告 acegi当年就是以这种配置方式,被冠以“每使用一次acegi,世界的某个地方就会有一个精灵死掉”的称号,请各位慎用。 既然不再使用http标签,我们就需要在配置文件中手工声明一个springSecurityFilterChain,这个bean会由web.xml中的DelegatingFilterProxy调用,注意bean的id必须为springSecurityFilterChain,否则系统启动时会报错。 下面我们就在springSecurityFilterChain中配置多个过滤器链。 这里我们配置了三套过滤器链,loginPageFilter负责处理/spring_security_login,httpSessionContextIntegrationFilter,authenticationProcessingFilter用来处理/j_spring_security_check*,最后由其他三个过滤器处理其外的所有URL请求。 loginPageFilter用来生成登录页面,authenticationProcessingFilter用来处理用户登录请求,只是它还需要与httpSessionContextIntegrationFilter一同起作用才能完成用户登录。这三个过滤器的配置如下: 其余的所有URL请求都使用httpSessionContextIntegrationFilter,exceptionTranslationFilter,filterInvocationInterceptor这三个过滤器的组合来处理,它们就是用来实际控制权限的部分。虽然这里只要配置三个过滤器,但实际上它们还需要和其他附属功能部件一起工作才能完成全县控制的功能。 比如exceptionTranslationFilter就需要authenticationEntryPoint来控制抛出异常时响应的策略和跳转的URL地址。 而filterInvocationInterceptor作为最核心的权限控制拦截器还需要authenticationManager和decisionManager的配合,这里我们为了不多害死其他精灵,所以就不贴配置内容了。 最后我们可以看到,使用了这种acegi中古老的配置方法能给我们控制每个组件的权力,但是整整82行代码的配置所实现功能,也就相当于使用了命名空间配置的20行代码,明显还是使用命名空间配置方式在可维护性上更有优势。 实例在ch210中。 第 40 章 自定义过滤器 常见的问题就是要在登录时多加几个参数时,默认的AuthenticationProcessFilter既不支持保存额外参数,也没有提供扩展点来实现这个功能,实际上就算是Spring Security-3.x中也因为只能配置一个handler,实际扩展时还是比较麻烦。所以这个时候一般的选择就是自定义过滤器了。 我们的目标是在登录时除了填写用户名和密码之外,再添加一个mark参数。 图 40.1. 登录时附加一个mark参数 我们的目的是在登录时将这个参数保存到session中,以备后用。为此我们要扩展AuthenticationProcessFilter: public class LoginFilter extends AuthenticationProcessingFilter { public Authentication attemptAuthentication(HttpServletRequest request) throws AuthenticationException { Authentication authentication = super.attemptAuthentication(request); String mark = request.getParameter("mark"); request.getSession().setAttribute("mark", "mark"); return authentication; } } 实际上我们只需要重写attemptAuthentication()这个方法,先调用super.attemptAuthentication()获得生成的Authentication,如果这部分没有抛出异常,我们下面再去从request中获得mark参数,再把这个参数保存到session里。最后返回authentication对象即可。 下面修改配置文件,在xml中添加一个名为loginFilter的bean,使用custom-filter将它加入到过滤器链中,放到原来的form-login的前面。 这样我们自定义的LoginFilter就会取代原本的AuthenticationProcessFilter处理用户登录,并在用户登录成功时将额外的mark参数保存到session中。 之后在jsp中,我们就可以直接通过${sessionScope['mark']}来获得mark的参数值。 图 40.2. 显示额外的参数 实例在ch211中。 第 41 章 使用用户组 Spring Security提供了用户组机制,可以将多个用户归纳在一个组中,进行统一授权。下面我们来研究一下如何在数据库中使用用户组保存用户的权限信息。 41.1. 数据库结构 在原数据库的基础上添加用户组所需的三张表: create table groups ( id bigint generated by default as identity(start with 0) primary key, group_name varchar_ignorecase(50) not null ); create table group_authorities ( group_id bigint not null, authority varchar(50) not null, constraint fk_group_authorities_group foreign key(group_id) references groups(id) ); create table group_members ( id bigint generated by default as identity(start with 0) primary key, username varchar(50) not null, group_id bigint not null, constraint fk_group_members_group foreign key(group_id) references groups(id) ); ER图如下所示: 图 41.1. 用户组ER图 下面开始初始化数据: insert into users(username,password,enabled) values('admin','admin',true); insert into users(username,password,enabled) values('user','user',true); insert into groups(id,group_name) values(1,'admin'); insert into groups(id,group_name) values(2,'user'); insert into group_authorities(group_id,authority) values(1,'ROLE_ADMIN'); insert into group_authorities(group_id,authority) values(2,'ROLE_USER'); insert into group_members(id,username,group_id) values(1,'admin',1); insert into group_members(id,username,group_id) values(2,'admin',2); insert into group_members(id,username,group_id) values(3,'user',2); 创建两个用户admin和user。 创建两个组admin和user。 为用户组授权,让admin组中的所有用户都拥有ROLE_ADMIN权限,user组中的所有用户都拥有ROLE_USER权限。 admin用户加入admin和user两个用户组,将user用户加入user用户组。 41.2. 修改配置文件 为jdbc-user-service添加一个参数就可以打开用户组功能。 这样系统就会使用这条sql语句从用户组表中查询用户拥有的权限。 之后可以启动实例ch212,使用admin和user用户测试授权情况。 实例在ch212中。 第 42 章 在JSF中使用Spring Security 在网上看到一个同志说不知道如何在JSF中使用Spring Security,这里特别做了一个例子演示一下,预先声明一下咱们对JSF并不太了解,例子略显简单,将就着用吧。 42.1. 修改过滤器支持forward 第一步就是修改web.xml中的过滤器配置,这样才能支持forward,否则默认只支持request方式的请求。 springSecurityFilterChain /* FORWARD REQUEST 42.2. 自定义登录页面 使用JSF写一个登陆页面。
然后创建对应的loginBean。 public String doLogin() throws IOException, ServletException { ExternalContext context = FacesContext.getCurrentInstance() .getExternalContext(); RequestDispatcher dispatcher = ((ServletRequest) context.getRequest()) .getRequestDispatcher("/j_spring_security_check"); dispatcher.forward((ServletRequest) context.getRequest(), (ServletResponse) context.getResponse()); FacesContext.getCurrentInstance().responseComplete(); // It's OK to return null here because Faces is just going to exit. return null; } 它主要负责在进行doLogin时,将请求转发到/j_spring_security进行用户登录认证。 42.3. 显示密码错误信息 为了在自定义页面回显密码错误的信息,需要定义一个监听器。 package com.family168.springsecuritybook.ch213; import javax.faces.application.FacesMessage; import javax.faces.context.FacesContext; import javax.faces.event.PhaseEvent; import javax.faces.event.PhaseId; import javax.faces.event.PhaseListener; import org.springframework.security.BadCredentialsException; import org.springframework.security.ui.AbstractProcessingFilter; public class LoginErrorPhaseListener implements PhaseListener { private static final long serialVersionUID = -1216620620302322995L; public void beforePhase(final PhaseEvent arg0) { Exception e = (Exception) FacesContext.getCurrentInstance() .getExternalContext() .getSessionMap() .get(AbstractProcessingFilter.SPRING_SECURITY_LAST_EXCEPTION_KEY); if (e instanceof BadCredentialsException) { FacesContext.getCurrentInstance().getExternalContext() .getSessionMap() .put(AbstractProcessingFilter.SPRING_SECURITY_LAST_EXCEPTION_KEY, null); FacesContext.getCurrentInstance() .addMessage(null, new FacesMessage(FacesMessage.SEVERITY_ERROR, "Username or password not valid.", null)); } } public void afterPhase(final PhaseEvent arg0) { } public PhaseId getPhaseId() { return PhaseId.RENDER_RESPONSE; } } 在解析之前判断是否存在BadCredentialsException异常,如果存在,就添加一条message,这条message会显示在登录页面上。 在faces-config.xml添加这个监听器即可生效。 com.family168.springsecuritybook.ch213.LoginErrorPhaseListener 实例在ch213中。 第 43 章 自定义会话管理 43.1. 默认策略的缺陷 默认提供的concurrent-session-control只支持两种策略: · 一是允许后登陆的用户将之前登陆的用户踢出系统,先登录的用户会看到“会话已失效”的提示。 这种策略实际上没办法阻止其他人在我登陆到系统之后,再使用我的账号登陆系统。最后会形成双方争先恐后的进行登录,不断将对方踢出系统的情况。 · 另一个是禁止用户使用已登录到系统的账户进行登录,后登陆的用户会在登录时看到“会话数量超过允许范围”的提示。 这种策略会导致一个比较麻烦的问题,如果用户不慎关闭了浏览器,没有通过logout退出系统,那么必须等到系统中用户的会话失效之后才能再次登录。如果用户不留神关了浏览器,就要再等半个小时才能再登录系统,这显然是没有道理的。 为了保证用户使用系统时不会受到其他人的影响,同时也保证不会同时有多个人使用同一个账号登陆系统,我们系统默认提供的第二种策略上进行一些修改。 简单来说,就是当用户已经登陆到系统时,如果又有人使用同一账号尝试登录系统,系统会判断当前用户的ip地址,如果ip地址与上次登录的ip地址相同,则注销上次登录的会话,允许当前用户登录系统。如果ip地址与上次登录的ip地址不同,而抛出异常,禁止用户登录系统。 这样既保证了同一时间只能有一个用户登录系统,又可以在用户操作失误关闭浏览器后可以再次登录系统。 43.2. 记录用户名与ip 默认情况下,系统使用SessionRegistry中只保存登录的用户名,为了比对ip地址,我们需要在登录时将用户的远程ip也保存到SessionRegistry中。 为此我们创建了SmartPrincipal类,它包含username和ip两个字段,并定义了equals()和hashCode(),这样可以保证它在HashMap中的操作不会出现问题。 package com.family168.springsecuritybook.ch214; import org.springframework.security.Authentication; import org.springframework.security.ui.WebAuthenticationDetails; import org.springframework.security.userdetails.UserDetails; import org.springframework.util.Assert; public class SmartPrincipal { private String username; private String ip; public SmartPrincipal(String username, String ip) { Assert.notNull(username, "username cannot be null (violation of interface contract)"); Assert.notNull(ip, "username cannot be null (violation of interface contract)"); this.username = username; this.ip = ip; } public boolean equalsIp(SmartPrincipal smartPrincipal) { return this.ip.equals(smartPrincipal.ip); } @Override public boolean equals(Object obj) { if (obj instanceof SmartPrincipal) { SmartPrincipal smartPrincipal = (SmartPrincipal) obj; return username.equals(smartPrincipal.username); } return false; } @Override public int hashCode() { return username.hashCode(); } } 43.3. 改造控制类 下一步要改造ConcurrentSessionControllerImpl和SessionRegistryImpl。 改造SessionRegistryImpl的原因比较复杂,因为除了ProviderManager会通过ConcurrentSessionController调用SessoinRegistry来注册登录用户之外,AuthenticationProcessFilter和SessionFixationProtectionFilter也会在处理会话伪造时直接调用SessionRegistry。 默认情况下,这些类都会把用户名直接注册到SessionRegistry中,因为我们现在需要获得用户ip,就需要把SmartPrincipal保存到SessionRegistry中,这里需要的就是做一步转换工作。 public class SmartSessionRegistry extends SessionRegistryImpl { public synchronized void registerNewSession(String sessionId, Object principal) { // // convert for SmartPrincipal // if (!(principal instanceof SmartPrincipal)) { principal = new SmartPrincipal(SecurityContextHolder.getContext() .getAuthentication()); } super.registerNewSession(sessionId, principal); } } 对于ConcurrentSessionController来说,就是为了实现在登录时判断用户的ip是否与之前登陆是保存的一样,本打算继承ConcurrentSessionControllerImpl,但是因为其中的exceptionIfMaximumExceeded属性未暴露给子类,所以只好重写了一个类,其中主要修改了allowableSessionsExceeded(), checkAuthenticationAllowed(), registerSuccessfulAuthentication()方法,具体代码请参考实例中的SmartConcurrentSessionController。 43.4. 修改配置文件 因为concurrent-session-controller标签不支持自定义的ConcurrentSessionController,我们只好使用其他办法将我们自定义的组件放入Spring Security中。 我们在authentication-manager中使用session-controller-ref将自定义的SmartConcurrentController引入命名空间中,它会自动将SmartSessionRegistry交给AuthenticationProcessFilter与SessionFixationProtectionFilter使用。 完成这些工作之后,我们需要找两台电脑来测试上述的策略是否可以正常运行。当有用户登录之后,其他人是无法在另外的电脑上使用同一账号登陆系统的,但是如果是当前登录的用户关闭了浏览器,这时再次登录系统应该是被允许的。 实例在ch214中。 第 44 章 匹配URL地址 恐怕很多同志都有一个误解,就是Spring Security中配置URL的时候,要么配置成一个特定的URL,比如/admin/index.jsp,要么配置为某个路径下所有的URL,比如/admin/**。如果你真的这么认为的话,那就太小看Spring Security了,/**只是默认提供的AntUrlPathMatcher所支持功能的一部分而已,下面我们就来破除迷信,深入讨论一下匹配URL地址这方面的功能。 44.1. AntUrlPathMatcher 我们默认使用的URL匹配器就是这个AntUrlPathMatcher,它来自于http://ant.apache.org/项目,是一种简单易懂的路径匹配策略。 它为我们提供了三种通配符。 · 通配符:? 示例:/admin/g?t.jsp 匹配任意一个字符,/admin/g?t.jsp可以匹配/admin/get.jsp和/admin/got.jsp或是/admin/gxt.do。不能匹配/admin/xxx.jsp。 · 通配符:* 示例:/admin/g?t.jsp 匹配任意多个字符,但不能跨越目录。/*/index.jsp可以匹配/admin/index.jsp和/user/index.jsp,但是不能匹配/index.jsp和/user/test/index.jsp。 · 通配符:** 示例:/**/index.jsp 可以匹配任意多个字符,可以跨越目录,可以匹配/index.jsp,/admin/index.jsp,/user/admin/index.jsp和/a/b/c/d/index.jsp 44.2. RegexUrlPathMatcher 如果默认的AntUrlPathMatcher无法满足需求,还可以选择使用更强大的RegexUrlPathMatcher,它支持使用正则表达式对URL地址进行匹配。 如果希望使用RegexUrlPathMatcher,就需要在配置文件中添加如下配置: 上面例子中就使用了正则表达式进行URL匹配,^/.*$与之前使用的/**意义相同,可以匹配任意一个请求。^(/.*)*/admin\.jsp$表示任意一个目录下的admin.jsp请求。 有关正则表达式的更多信息请去网上找一下吧,与它相关的资源太多了。 实例在ch215。 44.3. lowercase-comparisons 与URL匹配相关的配置还有一个lowercase-comparisons,它的功能是在进行URL匹配前是否需要自动将URL中的字符都转换成小写。 如果我们没有设置lowercase-comparisons的值,那么在默认情况下AntUrlPathMatcher会在每次匹配URL之前都将URL中的字符转换为小写,而RegexUrlPathMatcher默认不会将URL中的字符转换为小写。也就是说默认情况下AntUrlPathMatcher的默认值是lowercase-comparisons="true",而RegexUrlPathMatcher的默认值是lowercase-comparisons="false"。 如果需要修改lowercase-comparisons参数的值,可以像下面这样修改配置。 第 45 章 配置过滤器 45.1. 标准过滤器 下面是命名空间所支持的所有过滤器名称,类型,位置,以及在命名空间中对应的配置。 表 45.1. 标准过滤器别名和顺序 别名 过滤器类 命名空间元素或属性 CHANNEL_FILTER ChannelProcessingFilter http/intercept-url CONCURRENT_SESSION_FILTER ConcurrentSessionFilter http/concurrent-session-control SESSION_CONTEXT_INTEGRATION_FILTER HttpSessionContextIntegrationFilter http LOGOUT_FILTER LogoutFilter http/logout X509_FILTER X509PreAuthenticatedProcessigFilter http/x509 PRE_AUTH_FILTER AstractPreAuthenticatedProcessingFilter Subclasses N/A CAS_PROCESSING_FILTER CasProcessingFilter N/A AUTHENTICATION_PROCESSING_FILTER AuthenticationProcessingFilter http/form-login OPENID_PROCESSING_FILTER OpenIDAuthenticationProcessingFilter N/A 别名 过滤器类 命名空间元素或属性 BASIC_PROCESSING_FILTER BasicProcessingFilter http/http-basic SERVLET_API_SUPPORT_FILTER SecurityContextHolderAwareRequestFilter http/@servlet-api-provision REMEMBER_ME_FILTER RememberMeProcessingFilter http/remember-me ANONYMOUS_FILTER AnonymousProcessingFilter http/anonymous EXCEPTION_TRANSLATION_FILTER ExceptionTranslationFilter http NTLM_FILTER NtlmProcessingFilter N/A FILTER_SECURITY_INTERCEPTOR FilterSecurityInterceptor http SWITCH_USER_FILTER SwitchUserProcessingFilter N/A 上面的表格中列出了命名空间中支持的所有标准过滤器以及它们对应的顺序,这些过滤器的顺序是至关重要的,只有按照正确的顺序进行配置才能让这些过滤器正常发挥作用,在ACEGI的年代有太多太多的问题都是由于过滤器摆放的位置不正确造成的。幸好我们现在可以使用命名空间,它会帮我们将使用到的过滤器按照正确的方式摆放在一起。 45.2. 在http中启用标准过滤器 如果我们什么也不配置,只使用标签的话,也会有五个默认的过滤器被创建出来,并放到过滤器链中。它们是: HttpSessionContextIntegrationFilter SecurityContextHolderAwareRequestFilter ExceptionTranslationFilter SessionFixationProtectionFilter FilterSecurityInterceptor 如果追求最小的过滤器链,可以为标签加上servlet-api-provision="false"。 这样就会禁用SecurityContextHolderAwareRequestFilter和SessionFixationProtectionFilter两个过滤器,只剩下三个过滤器会被自动创建了。 这三个过滤器在系统中拥有十分特殊的含义,它们三个是命名空间限定的最核心过滤器,如果我们使用了命名空间的配置方式,那么这三个过滤器就是不可替换的,如果使用了 妄图替换其中任何一个过滤器,都会抛出异常,从而导致系统无法启动。 如果在中设置了auto-config="true"就会默认启用多个常用的过滤器,实际上就相当于如下配置: 使用auto-config="true"就等于在中添加了五个标签,同时会启用“用户注销”,“基于表单认证”,“http basic认证”,“记忆登录信息”,“匿名认证”五个功能。这五个功能的实际效果可以参考第 9 章 图解过滤器。 45.3. 为自定义过滤器设置位置 如果我们要自定义一个过滤器,就需要使用custom-filter标签,将过滤器加入过滤器链中才能实际起作用。在custom-filter中可以使用以下设置好的位置: "FIRST" "CHANNEL_FILTER" "CONCURRENT_SESSION_FILTER" "SESSION_CONTEXT_INTEGRATION_FILTER" "LOGOUT_FILTER" "X509_FILTER" "PRE_AUTH_FILTER" "CAS_PROCESSING_FILTER" "AUTHENTICATION_PROCESSING_FILTER" "OPENID_PROCESSING_FILTER" "BASIC_PROCESSING_FILTER" "SERVLET_API_SUPPORT_FILTER" "REMEMBER_ME_FILTER" "ANONYMOUS_FILTER" "EXCEPTION_TRANSLATION_FILTER" "NTLM_FILTER" "FILTER_SECURITY_INTERCEPTOR" "SWITCH_USER_FILTER" "LAST" 在custom-filter中可以使用before|position|after三种方式,将自定义过滤器放在对应名称的位置上,或者位置之前,或者位置之后。除了表示最前面的FIRST和表示最后面的LAST之外,这里的每个名称都对应着一个标准过滤器,我们可以在上面的章节中找到其对应的位置,要记住SESSION_CONTEXT_INTEGRATION_FILTER, EXCEPTION_TRANSLATION_FILTER和FILTER_SECURITY_INTERCEPTOR三个过滤器是不可替换的,不能对它们使用position。 部分 IV. ACL篇 Access Control List是一个很容易被人们提起的功能,比如业务员甲只能查看自己签的合同信息,不能看到业务员乙签的合同信息。这个功能在Spring Security中也有支持,但是配置比较,namespace方式只对afterInvocation做了一些支持,而且暂时没有找到虎牙子很好的解决方案。 第 46 章 ACL基本操作 ACL即访问控制列表(Access Controller List),它是用来做细粒度权限控制所用的一种权限模型。对ACL最简单的描述就是两个业务员,每个人只能查看操作自己签的合同,而不能看到对方的合同信息。 下面我们会介绍Spring Security中是如何实现ACL的。 46.1. 准备数据库和aclService ACL所需的四张表,表结构见附录:附录 E, 数据库表结构。 然后我们需要配置aclService,它负责与数据库进行交互。 46.1.1. 为acl配置cache 默认使用ehcache,spring security提供了一些默认的实现类。 在ehcache.xml中配置对应的aclCache缓存策略。 46.1.2. 配置lookupStrategy 简单来说,lookupStrategy的作用就是从数据库中读取信息,把这些信息提供给aclService使用,所以我们要为它配置一个dataSource,配置中还可以看到一个aclCache,这就是上面我们配置的缓存,它会把资源最大限度的利用起来。 中间一部分可能会让人感到困惑,为何一次定义了三个adminRole呢?这是因为一旦acl信息被保存到数据库中,无论是修改它的从属者,还是变更授权,抑或是修改其他的ace信息,都需要控制操作者的权限,这里配置的三个权限将对应于上述的三种修改操作,我们把它配置成只有ROLE_ADMIN才能执行这三种修改操作。 46.1.3. 配置aclService 当我们已经拥有了dataSource, lookupStrategy和aclCache的时候,就可以用它们来组装aclService了,之后所有的acl操作都是基于aclService展开的。 46.2. 使用aclService管理acl信息 当我们添加了一条信息,要在acl中记录这条信息的ID,所有者,以及对应的授权信息。下列代码在添加信息后执行,用于添加对应的acl信息。 ObjectIdentity oid = new ObjectIdentityImpl(Message.class, message.getId()); MutableAcl acl = mutableAclService.createAcl(oid); acl.insertAce(0, BasePermission.ADMINISTRATION, new PrincipalSid(owner), true); acl.insertAce(1, BasePermission.DELETE, new GrantedAuthoritySid("ROLE_ADMIN"), true); acl.insertAce(2, BasePermission.READ, new GrantedAuthoritySid("ROLE_USER"), true); mutableAclService.updateAcl(acl); 第一步,根据class和id生成object的唯一标示。 第二步,根据object的唯一标示,创建一个acl。 第三步,为acl增加ace,这里我们让对象的所有者拥有对这个对象的“管理”权限,让“ROLE_ADMIN”拥有对这个对象的“删除”权限,让“ROLE_USER”拥有对这个对象的“读取”权限。 最后,更新acl信息。 当我们删除对象时,也要删除对应的acl信息。下列代码在删除信息后执行,用于删除对应的acl信息。 ObjectIdentity oid = new ObjectIdentityImpl(Message.class, id); mutableAclService.deleteAcl(oid, false); 使用class和id可以唯一标示一个对象,然后使用deleteAcl()方法将对象对应的acl信息删除。 46.3. 使用acl控制delete操作 上述代码中,除了对象的拥有者之外,我们还允许“ROLE_ADMIN”也可以删除对象,但是我们不会允许除此之外的其他用户拥有删除对象的权限,为了限制对象的删除操作,我们需要修改Spring Security的默认配置。 首先要增加一个对delete操作起作用的表决器。 它只对Message这个类起作用,而且可以限制只有管理和删除权限的用户可以执行删除操作。 然后要将这个表决器添加到AccessDecisionManager中。 现在AccessDecisionManager中有两个表决器了,除了默认的RoleVoter之外,又多了一个我们刚刚添加的aclMessageDeleteVoter。 现在可以把新的AccessDecisionManager赋予全局方法权限管理器了。 然后我们就可以在MessageService.java中使用Secured注解,控制删除操作了。 @Transactional @Secured("ACL_MESSAGE_DELETE") public void remove(Message message) { list.remove(message); ObjectIdentity oid = new ObjectIdentityImpl(Message.class, id); mutableAclService.deleteAcl(oid, false); } 实际上,我们最好不要让没有权限的操作者看到remove这个链接,可以使用taglib隐藏当前用户无权看到的信息。 | Remove 8, 16是acl默认使用的掩码,8表示DELETE,16表示ADMINISTRATOR,当用户不具有这些权限的时候,他在页面上就看不到remove链接,也就无法执行操作了。 这比让用户可以执行remove操作,然后跑出异常,警告访问被拒绝要友好得多。 46.4. 控制用户可以看到哪些信息 当用户无权查看一些信息时,我们需要配置afterInvocation,使用后置判断的方式,将用户无权查看的信息,从MessageService返回的结果集中过滤掉。 后置判断有两种形式,一种用来控制单个对象,另一种可以过滤集合。 afterAclRead可以控制单个对象是否可以显示,afterAclCollectionRead则用来过滤集合中哪些对象可以显示。[6] 对这两个bean都是用了custom-after-invocation-provider标签,将它们加入的后置判断的行列,下面我们为MessageService.java中的对应方法添加Secured注解,之后它们就可以发挥效果了。 @Secured({"ROLE_USER", "AFTER_ACL_READ"}) public Message get(Long id) { for (Message message : list) { if (message.getId().equals(id)) { return message; } } return null; } @Secured({"ROLE_USER", "AFTER_ACL_COLLECTION_READ"}) public List getAll() { return list; } 以上就是Spring Security支持的ACL,这里用到了数据库,方法拦截器,注解,taglib,基本可以说ACL就是囊括了我们之前所有讨论过功能的集合体,下一步我们会继续研究ACL的一些高级特性。 [6] 这个地方会引发一个经典的问题,虎牙子,一般的思路是使用动态SQL的方式,在查询的时候就过滤掉无权显示的信息,但随着查询条件的复杂化,当出现SQL语句长度超过DBMS最大限制时,咱们就可以去撞墙了。 第 47 章 管理acl 这章介绍一些有关管理acl的内容,包括管理多个domain类和动态授权与收回授权。 47.1. 管理多个domain类 上一章中我们演示了如何使用自定义Voter对单个domain类进行权限控制,如果我们需要对多个domain类实现acl权限控制时,不必给每一个类都配置一个Voter,只需要定义一个接口,让所有需要进行acl权限控制的domain类实现这个接口,然后在Voter中配置这个统一接口就可以了。 示例中我们定义了一个AclDomainClass接口。 package com.family168.springsecuritybook.ch302; public interface AclDomainClass { } 然后让其他domain都实现这个接口。 public class Account implements Serializable, AclDomainClass { // .... } 这样就可以在配置文件中配置统一的Voter。 这样,aclDeleteVoter就可以处理所有实现了AclDomainClass接口的类,而在数据库中依然会保存实现类的具体类型,不会因为在Voter中使用了同一个接口类而造成影响。 47.2. 动态授权与收回授权 现在acl的好处是可以通过aclService自由管理acl和ace的信息,比如我们可以为其他人授权,让其他人可以查看自己的消息,也可以收回这些授权。 47.2.1. 获得对象的acl权限 可以通过domain类的类型和id,获得对应acl中的所有ace信息。 public void list(HttpServletRequest request, HttpServletResponse response) throws Exception { Long id = Long.valueOf(request.getParameter("id")); String clz = request.getParameter("clz"); Acl acl = null; if (clz.equals("account")) { Account account = new Account(); account.setId(id); acl = getAclService().readAclById(new ObjectIdentityImpl(account)); } else if (clz.equals("contract")) { Contract contract = new Contract(); contract.setId(id); acl = getAclService().readAclById(new ObjectIdentityImpl(contract)); } else if (clz.equals("message")) { Message message = new Message(); message.setId(id); acl = getAclService().readAclById(new ObjectIdentityImpl(message)); } request.setAttribute("acl", acl); request.getRequestDispatcher("/permission-list.jsp").forward(request, response); } 在jsp中使用如下方式显示ace中的权限实体以及对应的权限。 ${item.sid.principal} | ${item.permission.mask} delete ace列表如下图所示: 图 47.1. 实现domain对象对应的所有ace信息 user表示权限实体的名称,16表示administration权限。这里表示当前的对应已经赋予了user用户管理权限。 47.2.2. 添加授权 可以将一个domain对象的acl权限授予其他人。 可以选择授权的人或角色,然后选择授权的具体权限。 图 47.2. 添加授权 提交后可以看到domain对象对应的acl权限增加了admin用户的read权限,这时admin用户就可以查看这个domain对象的而信息了。 对应代码如下所示: @Transactional public void addPermission(long id, String clz, String recipient, int mask) { PrincipalSid sid = new PrincipalSid(recipient); Permission permission = BasePermission.buildFromMask(mask); ObjectIdentity oid = null; if (clz.equals("account")) { oid = new ObjectIdentityImpl(Account.class, id); } else if (clz.equals("contract")) { oid = new ObjectIdentityImpl(Contract.class, id); } else if (clz.equals("message")) { oid = new ObjectIdentityImpl(Message.class, id); } MutableAcl acl; try { acl = (MutableAcl) mutableAclService.readAclById(oid); } catch (NotFoundException nfe) { acl = mutableAclService.createAcl(oid); } acl.insertAce(acl.getEntries().length, permission, sid, true); mutableAclService.updateAcl(acl); } 47.2.3. 收回授权 可以将已经授权的权限删除。 对应代码如下: @Transactional public void deletePermission(long id, String clz, String recipient, int mask) { PrincipalSid sid = new PrincipalSid(recipient); Permission permission = BasePermission.buildFromMask(mask); ObjectIdentity oid = null; if (clz.equals("account")) { oid = new ObjectIdentityImpl(Account.class, id); } else if (clz.equals("contract")) { oid = new ObjectIdentityImpl(Contract.class, id); } else if (clz.equals("message")) { oid = new ObjectIdentityImpl(Message.class, id); } MutableAcl acl = (MutableAcl) mutableAclService.readAclById(oid); AccessControlEntry[] entries = acl.getEntries(); for (int i = 0; i < entries.length; i++) { if (entries[i].getSid().equals(sid) && entries[i].getPermission().equals(permission)) { acl.deleteAce(i); } } mutableAclService.updateAcl(acl); } 介于权限主体和权限可能有多种组合,所以我们只删除两者都完全匹配的ace信息,操作成功后可以看到用户的acl权限已经被收回了。 实例在ch302。 第 48 章 acl自动提醒 自定义AfterInvocationProvider,在创建实体类时自动创建对应的acl权限,在删除实体类的时候,自动删除acl权限。 48.1. 自动创建acl 首先一个目标是监听实体类的创建,在创建后生成对应的acl信息。 根据实体类创建acl信息的代码如下所示: ObjectIdentity oid = new ObjectIdentityImpl(object); MutableAcl acl = mutableAclService.createAcl(oid); acl.insertAce(0, BasePermission.ADMINISTRATION, new PrincipalSid(getUsername()), true); 需要创建一个CreateAclEntryAfterInvocationProvider,将它注册到Spring Security中就可以在方法调用后进行对应的操作了。 public Object decide(Authentication authentication, Object object, ConfigAttributeDefinition config, Object returnedObject) throws AccessDeniedException { Iterator iter = config.getConfigAttributes().iterator(); while (iter.hasNext()) { ConfigAttribute attr = (ConfigAttribute) iter.next(); if (this.supports(attr)) { Object domainObject = getDomainObjectInstance(object, processDomainObjectClass); Iterator cit = this.handlers.iterator(); while (cit.hasNext()) { AclHandler handler = (AclHandler) cit.next(); if (handler.supports(domainObject, returnedObject)) { handler.create(authentication, domainObject, config, returnedObject); break; } } } } return returnedObject; } xml文件中的配置如下: 为了指定在哪些方法调用后执行CreateAclEntryAfterInvocationProvider,我们在方法上使用AFTER_ACL_CREATE注解。 @Transactional @Secured({"ROLE_USER", "AFTER_ACL_CREATE"}) public void save(Account account) { list.add(account); } 这样在save(account)方法执行后,就会自动根据account这个对象生成对应的acl信息。 48.2. 自动删除acl 自动删除acl的步骤与创建acl的大致相同。 首先来看一下删除acl信息的代码: ObjectIdentity oid = new ObjectIdentityImpl(object); mutableAclService.deleteAcl(oid, false); 其次是创建DeleteAclEntryAfterInvocationProvider。 public Object decide(Authentication authentication, Object object, ConfigAttributeDefinition config, Object returnedObject) throws AccessDeniedException { Iterator iter = config.getConfigAttributes().iterator(); while (iter.hasNext()) { ConfigAttribute attr = (ConfigAttribute) iter.next(); if (this.supports(attr)) { Object domainObject = getDomainObjectInstance(object, processDomainObjectClass); Iterator cit = this.handlers.iterator(); while (cit.hasNext()) { AclHandler handler = (AclHandler) cit.next(); if (handler.supports(domainObject, returnedObject)) { handler.delete(authentication, domainObject, config, returnedObject); break; } } } } return returnedObject; } xml中的配置如下所示: 最后记得使用AFTER_ACL_DELETE对方法进行注解。 @Transactional @Secured({"ROLE_USER", "AFTER_ACL_DELETE"}) public void remove(Account account) { list.remove(account); } 48.3. 根据id删除acl 上面演示的remove()方法中需要传入account对象的实例才能起作用,有时我们希望使用removeById(id)的方式,只通过对象的id就可以删除这个对象的实例。这时AfterInvocation就无法判断当前的id实际对应的是哪个类,因此也就无法删除对应的acl信息了。 为了解决这个问题,我们引入了AclDomainAware注解。 @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface AclDomainAware { Class value(); } 可以为它设置一个Class值,使用它标示当前方法中id所对应的类型。 @Transactional @Secured({"ACL_DELETE", "AFTER_ACL_DELETE"}) @AclDomainAware(Account.class) public void removeById(Long id) { Account account = this.get(id); list.remove(account); } 这样DeleteAclEntryAfterInvocationProvider就可以通过AclDomainAware中设置的类型和id的值找到对应实例的信息并删除之。 对AclDomainAware进行扩展后,我们也可以让它负责处理那些参数中包含id的情况,扩展AclEntryVoter,让它可以使用AclDomainAware中的类型和id值生成的acl信息,以此进行acl的权限控制。 IdParameterAclEntryVoter代码如下所示: public class IdParameterAclEntryVoter extends AclEntryVoter { public IdParameterAclEntryVoter(AclService aclService, String processConfigAttribute, Permission[] requirePermission) { super(aclService,processConfigAttribute,requirePermission); } @Override protected Object getDomainObjectInstance(Object secureObject) { Object[] args; Class[] params; if (secureObject instanceof MethodInvocation) { MethodInvocation invocation = (MethodInvocation) secureObject; params = invocation.getMethod().getParameterTypes(); args = invocation.getArguments(); } else { JoinPoint jp = (JoinPoint) secureObject; params = ((CodeSignature) jp.getStaticPart().getSignature()).getParameterTypes(); args = jp.getArgs(); } for (int i = 0; i < params.length; i++) { if (getProcessDomainObjectClass().isAssignableFrom(params[i])) { return args[i]; } } MethodInvocation invocation = (MethodInvocation) secureObject; Method method = invocation.getMethod(); Serializable id = null; for (int i = 0; i < params.length; i++) { if (Serializable.class.isAssignableFrom(params[i])) { id = (Serializable) invocation.getArguments()[i]; break; } } if (id == null) { throw new AuthorizationServiceException("MethodInvocation: " + invocation + " did not provide any ID argument."); } if(method.isAnnotationPresent(AclDomainAware.class)) { try{ Class domainClass= method.getAnnotation(AclDomainAware.class) .value(); Object instance = domainClass.newInstance(); Method setter = domainClass.getDeclaredMethod("setId", new Class[]{id.getClass()}); setter.invoke(instance, new Object[]{id}); return instance; }catch(Exception ex) { ex.printStackTrace(); } } throw new AuthorizationServiceException("Secure object: " + secureObject + " did not provide any argument of type: " + getProcessDomainObjectClass()); } } 实例在ch303。 部分 V. 最佳实践篇 根据不同的权限模型,实现多种完整的权限管理系统。 · default: 使用Spring Security默认提供的数据库结构,实现用户信息和权限管理后台。 · rbac0: RBAC0模型。 · rbac1: RBAC1模型。 · rbac2: RBAC2模型。 · rbac3: RBAC3模型。 · acl: 行级细粒度权限控制。 · ajax: 全站式ajax的web 2.0系统中应用权限管理。 · web: 在默认数据库结构的基础上,实现web所需的“用户注册”,“用户激活”,“安全提问”,“找回密码”功能。 · group: 分级组织管理模型。 · adapter: 权限适配器。 第 49 章 最简控制台 所谓的最简,实际上就是尽量利用现有资源,实现一个可管理的权限后台。 我们将使用Spring Security提供的filter实现URL级的权限控制,使用Spring Security提供的UserDetailsManager实现用户管理,其中会包含用户密码加密和用户信息缓存。麻雀虽小,五脏俱全,如果想为自己的系统添加最简的权限后台,这一章将是不二之选。 我们将在这个简易的控制台中实现如下功能:浏览用户,新增用户,修改用户,删除用户,修改密码,用户授权。 49.1. 平台搭建 选择maven2作为主要的构建工具,以便更加方便的管理第三方依赖,这章使用的依赖如下所示: [INFO] [dependency:tree {execution: default-cli}] [INFO] com.family168.springsecuritybook:ch401:war:0.1 [INFO] +- org.springframework.security:spring-security-taglibs:jar:2.0.5.RELEASE:compile [INFO] | +- org.springframework.security:spring-security-core:jar:2.0.5.RELEASE:compile [INFO] | | +- org.springframework:spring-core:jar:2.0.8:compile [INFO] | | +- org.springframework:spring-context:jar:2.0.8:compile [INFO] | | | \- aopalliance:aopalliance:jar:1.0:compile [INFO] | | +- org.springframework:spring-aop:jar:2.0.8:compile [INFO] | | +- org.springframework:spring-support:jar:2.0.8:runtime [INFO] | | +- commons-logging:commons-logging:jar:1.1.1:compile [INFO] | | +- commons-codec:commons-codec:jar:1.3:compile [INFO] | | \- commons-collections:commons-collections:jar:3.2:compile [INFO] | +- org.springframework.security:spring-security-acl:jar:2.0.5.RELEASE:compile [INFO] | | \- org.springframework:spring-jdbc:jar:2.0.8:compile [INFO] | | \- org.springframework:spring-dao:jar:2.0.8:compile [INFO] | \- org.springframework:spring-web:jar:2.0.8:compile [INFO] | \- org.springframework:spring-beans:jar:2.0.8:compile [INFO] +- org.hsqldb:hsqldb:jar:1.8.0.10:compile [INFO] +- javax.servlet:servlet-api:jar:2.4:provided [INFO] +- taglibs:standard:jar:1.1.2:compile [INFO] +- javax.servlet:jstl:jar:1.1.2:compile [INFO] \- net.sf.ehcache:ehcache:jar:1.6.0:compile 项目的目录结构如下: + ch401/ + src/ + main/ + java/ + com/ + family168/ + springsecuritybook/ + ch401 * UserBean.java * UserManager.java * UserServlet.java + resources/ + hsqldb/ * test.properties * test.scripts * applicationContext-security.xml * applicatoinContext-service.xml + webapp/ + includes/ * error.jsp * header.jsp * message.jsp * meta.jsp * taglibs.jsp + scripts/ * jquery.min.js * jquery.validate.pack.js * messages_cn.js + WEB-INF/ * web.xml * index.jsp * login.jsp * user-changePassword.jsp * user-create.jsp * user-edit.jsp * user-list.jsp * user-view.jsp + test/ + resources/ * pom.xml src/main/java/目录下放着所有的java源代码。 src/main/resources/hsqldb/目录下放置着hsqldb数据库表结构和演示数据。 权限控制相关的配置文件。 进行用户管理和权限管理所需的配置文件。 src/main/webapp/目录下放着web应用所需的JavaScript脚本与jsp文件。 49.2. 用户登录 用户需要登录系统才能进入系统进行操作。有关自定义登录页面的介绍,请参考之前的章节。第 4 章 自定义登陆页面 图 49.1. 用户登录 我们为了演示的需要预设了两个用户admin/admin和user/user,打开演示用的数据库文件test.scripts可以看到用户信息以及加密过的用户密码。 INSERT INTO USERS VALUES('admin','ceb4f32325eda6142bd65215f4c0f371',TRUE) INSERT INTO USERS VALUES('user','47a733d60998c719cf3526ae7d106d13',TRUE) INSERT INTO AUTHORITIES VALUES('admin','ROLE_ADMIN') INSERT INTO AUTHORITIES VALUES('admin','ROLE_USER') INSERT INTO AUTHORITIES VALUES('user','ROLE_USER') 为了提升安全等级,我们对密码使用了md5和saltValue进行加密,对应的配置文件在applicationContext-securit.xml中。 49.3. 用户信息列表 用户登录成功之后即进入用户信息列表。 图 49.2. 用户信息列表 显示所有用户信息的请求地址为/user.do?action=list,这个请求将交由UserServlet.java处理,在list()方法中调用UserManager.java的getAll()方法获得数据库中所有的用户信息。 /** * get all of user. */ public List getAll() { String sql = "select username,password,enabled,authority" + " from users u inner join authorities a on u.username=a.username"; List
登陆失败
${sessionScope['SPRING_SECURITY_LAST_EXCEPTION'].message}
/j_spring_security_check,提交登陆信息的URL地址。 自定义form时,要把form的action设置为/j_spring_security_check。注意这里要使用绝对路径,避免登陆页面存放的页面可能带来的问题。[4] j_username,输入登陆名的参数名称。 j_password,输入密码的参数名称 _spring_security_remember_me,选择是否允许自动登录的参数名称。 可以直接把这个参数设置为一个checkbox,无需设置value,Spring Security会自行判断它是否被选中。 以上介绍了自定义页面上Spring Security所需的基本元素,这些参数名称都采用了Spring Security中默认的配置值,如果有特殊需要还可以通过配置文件进行修改。 4.4. 测试一下 经过以上配置,我们终于使用了一个自己创建的登陆页面替换了原来Spring Security默认提供的登录页面了。我们不仅仅是做个样子,而是实际配置了各个Spring Security所需的参数,真正将自定义登陆页面与Spring Security紧紧的整合在了一起。以下是使用自定义登陆页面实际运行时的截图。 图 4.1. 进入登录页面 图 4.2. 用户登陆失败 实例见ch004。 [2] 有关匿名用户的知识,我们会在之后的章节中进行讲解。 [3] 登陆成功后跳转策略的知识,我们会在之后的章节中进行讲解。 [4] 关于绝对路径和相对路径的详细讨论,请参考http://family168.com/tutorial/jsp/html/jsp-ch-03.html#jsp-ch-03-04-01 第 5 章 使用数据库管理资源 国内对权限系统的基本要求是将用户权限和被保护资源都放在数据库里进行管理,在这点上Spring Security并没有给出官方的解决方案,为此我们需要对Spring Security进行扩展。 5.1. 数据库表结构 这次我们使用五张表,user用户表,role角色表,resc资源表相互独立,它们通过各自之间的连接表实现多对多关系。 -- 资源 create table resc( id bigint, name varchar(50), res_type varchar(50), res_string varchar(200), priority integer, descn varchar(200) ); alter table resc add constraint pk_resc primary key(id); alter table resc alter column id bigint generated by default as identity(start with 1); -- 角色 create table role( id bigint, name varchar(50), descn varchar(200) ); alter table role add constraint pk_role primary key(id); alter table role alter column id bigint generated by default as identity(start with 1); -- 用户 create table user( id bigint, username varchar(50), password varchar(50), status integer, descn varchar(200) ); alter table user add constraint pk_user primary key(id); alter table user alter column id bigint generated by default as identity(start with 1); -- 资源角色连接表 create table resc_role( resc_id bigint, role_id bigint ); alter table resc_role add constraint pk_resc_role primary key(resc_id, role_id); alter table resc_role add constraint fk_resc_role_resc foreign key(resc_id) references resc(id); alter table resc_role add constraint fk_resc_role_role foreign key(role_id) references role(id); -- 用户角色连接表 create table user_role( user_id bigint, role_id bigint ); alter table user_role add constraint pk_user_role primary key(user_id, role_id); alter table user_role add constraint fk_user_role_user foreign key(user_id) references user(id); alter table user_role add constraint fk_user_role_role foreign key(role_id) references role(id); user表中包含用户登陆信息,role角色表中包含授权信息,resc资源表中包含需要保护的资源。 ER图如下所示: 图 5.1. 数据库表关系 5.2. 初始化数据 创建的两个用户分别对应“管理员”角色和“用户”角色。而“管理员”角色可以访问“/admin.jsp”和“/**”,“用户”角色只能访问“/**”。 insert into user(id,username,password,status,descn) values(1,'admin','admin',1,'管理员'); insert into user(id,username,password,status,descn) values(2,'user','user',1,'用户'); insert into role(id,name,descn) values(1,'ROLE_ADMIN','管理员角色'); insert into role(id,name,descn) values(2,'ROLE_USER','用户角色'); insert into resc(id,name,res_type,res_string,priority,descn) values(1,'','URL','/admin.jsp',1,''); insert into resc(id,name,res_type,res_string,priority,descn) values(2,'','URL','/**',2,''); insert into resc_role(resc_id,role_id) values(1,1); insert into resc_role(resc_id,role_id) values(2,1); insert into resc_role(resc_id,role_id) values(2,2); insert into user_role(user_id,role_id) values(1,1); insert into user_role(user_id,role_id) values(1,2); insert into user_role(user_id,role_id) values(2,2); 5.3. 实现从数据库中读取资源信息 Spring Security没有提供从数据库获得获取资源信息的方法,实际上Spring Security甚至没有为我们留一个半个的扩展接口,所以我们这次要费点儿脑筋了。 首先,要搞清楚需要提供何种类型的数据,然后,寻找可以让我们编写的代码替换原有功能的切入点,实现了以上两步之后,就可以宣布大功告成了。 5.3.1. 需要何种数据格式 从配置文件上可以看到,Spring Security所需的数据应该是一系列URL网址和访问这些网址所需的权限: ${sessionScope['SPRING_SECURITY_LAST_EXCEPTION'].message}
username :
如果想在程序中获得当前登陆用户对应的对象。 UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext() .getAuthentication() .getPrincipal(); 如果想获得当前登陆用户所拥有的所有权限。 GrantedAuthority[] authorities = userDetails.getAuthorities(); 关于UserDetails是如何放到SecuirtyContext中去的,以及Spring Security所使用的TheadLocal模式,我们会在后面详细介绍。这里我们已经了解了如何获得当前登陆用户的信息。 [5] 所谓字典攻击,就是指将大量常用字符串使用md5加密,形成字典库,然后将一段由md5演算得到的未知字符串,在字典库中进行搜索,当发现匹配的结果时,就可以获得对应的加密前的字符串内容。 第 7 章 自定义访问拒绝页面 在我们的例子中,user用户是不能访问/admin.jsp页面的,当我们使用user用户登录系统之后,访问/admin.jsp时系统默认会返回403响应。 图 7.1. 403响应 如果我们希望自定义访问拒绝页面,只需要随便创建一个jsp页面,让后将这个页面的位置放到配置文件中。 下面创建一个accessDenied.jsp <%@ page contentType="text/html;charset=UTF-8"%> Access Denied
访问被拒绝
${requestScope['SPRING_SECURITY_403_EXCEPTION'].message}
${requestScope['SPRING_SECURITY_403_EXCEPTION'].message}
下一步修改配置文件,添加自定义访问拒绝页面的地址。
username : | authorities: ${authority.authority}
当用户访问系统时,就会看到如下信息,这时他还没有进行登录。 图 15.1. 匿名登录 这里显示的是分配给所有未登录用户的一个默认用户名roleAnonyMous,拥有的权限是ROLE_ANONYMOUS。我们可以看到系统已经把匿名用户当做了一个合法有效的用户进行处理,可以获得它的用户名和拥有的权限,而不需判断SecurityContext中是否为空。 实际上,我们完全可以把匿名用户像一个正常用户那样进行配置,我们可以在配置文件中直接使用ROLE_ANONYMOUS指定它可以访问的资源。