一、Spring Security 应用的概述
鉴于目前微服务的兴起,Spring周边方案的普及,以及 Spring Security 强大的和高度可定制的优良特性,最近关注了一下相关内容,顺便留个笔记心得,希望对大家有所帮助。
Spring Security 权限方案针对不同场景需求有多种不同的使用方法,在此,我们最终描述的是如何采用数据库存储配置,并通过自定义过滤器的实现方式,来进行对权限的权利,希望这个过程能加深对Spring Security的理解,如有初学者阅读,建议先简单了解下Spring Security 框架,以避免遭遇太多的疑惑。
先说大概,Spring Security,包括绝大部分的安全框架,都可以简单理解为两个核心:一个是认证,即看看这个请求用户存在不存在啊,密码对不对啊等,认证,来确保请求用户的合法性;另一个就是鉴权,即看看这个访问的资源,有没有权限,这个决定用户能做什么,不能做什么。敲黑板,两个重点核心:认证!鉴权!下面,我们将尝试下,看看在 Spring Security 框架内是如何完成这些功能的。
在这里,我们不准备剖析 Spring Security 底层的基本逻辑,有些还需要就源码进行解读,这里只讲应用层面的东西。
先说认证,与本次实现密切相关的几个类或接口,是UserDetails、UserDetailsService、AuthenticationProvider,我们可以这么理解:UserDetails是用来封装用户的,用户的帐号信息啊、一些权限啊,帐号状态啊等信息,从数据库那里拿到,首先是要封装成UserDetails的样子,才可以在Spring Security框架中使用的;UserDetailsService,顾名思义,处理UserDetails的Service,它是提供去查询账号信息并封装成UserDetails的服务;AuthenticationProvider的主要工作是负责认证,从登录请求那里拿到帐号密码之类,然后再跟从数据库资源那里得到的UserDetails进行对比确认,如果发现不对劲儿,该报错报错,该提示提示,如果OK,则把这些信息揉巴成一团,封装成一个包含所有信息的认证对象,交给 Spring Security 框架进行管理,供后边有需要的时候随时取用。
接下来说鉴权,Spring Security 的鉴权方式有多种,我们大概捋一下,这里我们重点讲述如何通过自定义过滤器的鉴权方式,来实现数据库配置权限的动态管理,与此密切相关的几个核心类或接口分别是:AbstractSecurityInterceptor(Filter)、FilterInvocationSecurityMetadataSource和AccessDecisionManager。我们可以这么理解,FilterInvocationSecurityMetadataSource是权限资源管理器,它的主要工作就是根据请求的资源(路径),从数据库获取相对应的权限信息;AccessDecisionManager类似权限管理判断器,负责校验当前认证用户的权限,是否可以访问;AbstractSecurityInterceptor就是前边这两个角色负责表演的地方,拿到访问资源所需的权限,和认证用户的权限,对比,出结果,如果出现对比不成功,分分钟抛要一个拒绝访问的异常,403forbidden了!
在这里先把这几个类或者接口,默默的混个眼熟,认证相关:UserDetails、UserDetailsService、AuthenticationProvider;鉴权相关:AbstractSecurityInterceptor(Filter)、FilterInvocationSecurityMetadataSource和AccessDecisionManager,谁是干啥的,谁跟谁什么关系,大概就是那么个意思了,也能猜出 Spring Security 是怎么工作的。
接下来还会介绍下 Spring Security 的核心配置类:WebSecurityConfigurerAdapter,它的主要职责就是配置配置哪些资源不需要权限限制啊,哪些需要啊等等,以及做一些综合性的配置操作,以及 Spring Security 本身的注册等。
以上是 Spring Security 应用的一个概述,目的是有个简单的了解,提前混个眼熟,便于思路连续性的展开。
二、springBoot项目初建
在eclipse上怎么创建maven项目,我们就不多说了,方式很多种;这里讲,本次 Spring Security 的实现要用到的依赖主要有 Spring MVC、Spring Security、Mybatis、thymeleaf,我们用自己最熟悉的方式建个maven项目,然后修改pom.xml文件如下:
pom.xml
4.0.0 org.springframework.boot spring-boot-starter-parent 2.0.1.RELEASE sec_test sec 0.0.1-SNAPSHOT war UTF-8 2.0.1.RELEASE org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-thymeleaf org.thymeleaf.extras thymeleaf-extras-springsecurity4 org.mybatis.spring.boot mybatis-spring-boot-starter 1.3.2 mysql mysql-connector-java runtime com.alibaba druid 1.0.19 com.google.code.gson gson 2.8.5 org.projectlombok lombok 1.16.20 provided ${project.name} maven-compiler-plugin 1.8 org.springframework.boot spring-boot-maven-plugin
Spring boot下,各个版本一般都是向下兼容略有不同,在这种简单的应用上基本体现不出太大的差异,我们遵循各自习惯去配置,开心就好,注意pom文件中,除了几个核心的,额外还有gson和lombok的引入,gson是为了方便输出对象日志;lombok是为了省去bean类中set/get方法,这个可以让代码看起来稍微简练些,首次使用需要提前安装下lombok的插件之类,感兴趣的可以自行百度下,也可以根据自己的习惯决定是否使用。
接下来我们在 src/main/resources 中创建一个 application.yml 作为springBoot项目的主配置文件,注意,这个.yml和.properties的配置方式,虽各有优劣长短,但功效是一样的,我们这里将采用 .yml 的方式,文件内容如下:
application.yml
server: port: 8090 application: name: sec spring: thymeleaf: mode: HTML5 encoding: UTF-8 content-type: text/html cache: false #开发时关闭缓存,不然没法看到实时页面! prefix: classpath:/public/ #配置页面文件路径 suffix: .html #配置页面默认后缀 datasource: url: jdbc:mysql://127.0.0.1:3306/sec?useUnicode=true&characterEncoding=UTF-8 username: root password: ******
这个配置文件就是设定一下服务端口啊,服务名称啊,还有thymeleaf相关的一些路径配置,以及一些数据源待用的参数,这个文件的配置参数会被系统默认加载,需要时直接取用,很方便。然后在主路径下创建一个含main方法的SecApplication类,做启动入口,如下:
SecApplication.java
package com.veiking; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; /** * 项目启动入口 * @author Veiking */ @SpringBootApplication public class SecApplication { public static void main(String[] args) { SpringApplication.run(SecApplication.class, args); } }
注意加标签@SpringBootApplication,表示这将是按照 Spring boot 项目的形式运行。然后直接右键运行启动,留意下输出窗口,看看什么情况,启动成功,注意,输出栏的日志里很突兀的大了这样一行代码:Using generated security password: XXXX7e44-e83c-460a-aeef-94249316XXXX ,这个是 Spring Security 自带默认的,用户名为user,密码就是这串UUID一样的串儿,接下来,我们浏览器输入:http://localhost:8090,敲回车,自动跳转到了http://localhost:8090/login的路径,我们可以看到一个框架本身自带的登录页面:
我们在窗口输入默认的用户名密码,提交,就得到了这样一个页面:
好了,初步的 Spring Security 项目验证通过,项目创建完成。
三、数据库信息的创建
这一波操作我们要创建本次实现要用的数据库表了,按照一般节奏,我们先来五张表:s_user、s_role、s_permission 和 s_user_role、s_role_permission,简单介绍下,就是用户、角色、权限资源和他们的关联关系表,他们结构如下:
s_user
s_role
s_permission
s_user_role
s_role_permission
我们顺便贴上结构代码,以便使用:
-- ---------------------------- -- Table structure for `s_user` -- ---------------------------- DROP TABLE IF EXISTS `s_user`; CREATE TABLE `s_user` ( `id` int(11) NOT NULL, `name` varchar(32) DEFAULT NULL, `password` varchar(32) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ---------------------------- -- Table structure for `s_user_role` -- ---------------------------- DROP TABLE IF EXISTS `s_user_role`; CREATE TABLE `s_user_role` ( `fk_user_id` int(11) DEFAULT NULL, `fk_role_id` int(11) DEFAULT NULL, KEY `union_key` (`fk_user_id`,`fk_role_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ---------------------------- -- Table structure for `s_role` -- ---------------------------- DROP TABLE IF EXISTS `s_role`; CREATE TABLE `s_role` ( `id` int(11) NOT NULL, `role` varchar(32) DEFAULT NULL, `describe` varchar(32) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ---------------------------- -- Table structure for `s_role_permission` -- ---------------------------- DROP TABLE IF EXISTS `s_role_permission`; CREATE TABLE `s_role_permission` ( `fk_role_id` int(11) DEFAULT NULL, `fk_permission_id` int(11) DEFAULT NULL, KEY `union_key` (`fk_role_id`,`fk_permission_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ---------------------------- -- Table structure for `s_permission` -- ---------------------------- DROP TABLE IF EXISTS `s_permission`; CREATE TABLE `s_permission` ( `id` int(11) NOT NULL, `permission` varchar(32) DEFAULT NULL, `url` varchar(32) DEFAULT NULL, `describe` varchar(32) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
接下来我们新增一些用户数据,admin、veiking、xiaoming,添加一些记录,大概意思是,admin拥有所有权限,veiking只有hello、index相关权限,xiaoming什么权限都没有,添加数据记录的脚本如下:
-- ---------------------------- -- Records of s_user -- ---------------------------- INSERT INTO `s_user` VALUES ('1', 'admin', 'admin'); INSERT INTO `s_user` VALUES ('2', 'veiking', 'veiking'); INSERT INTO `s_user` VALUES ('3', 'xiaoming', 'xiaoming'); -- ---------------------------- -- Records of s_user_role -- ---------------------------- INSERT INTO `s_user_role` VALUES ('1', '1'); INSERT INTO `s_user_role` VALUES ('2', '2'); -- ---------------------------- -- Records of s_role -- ---------------------------- INSERT INTO `s_role` VALUES ('1', 'R_ADMIN', '大总管,所有权限'); INSERT INTO `s_role` VALUES ('2', 'R_HELLO', '说hello相关的权限'); -- ---------------------------- -- Records of s_role_permission -- ---------------------------- INSERT INTO `s_role_permission` VALUES ('1', '1'); INSERT INTO `s_role_permission` VALUES ('1', '2'); INSERT INTO `s_role_permission` VALUES ('1', '3'); INSERT INTO `s_role_permission` VALUES ('2', '1'); INSERT INTO `s_role_permission` VALUES ('2', '3'); -- ---------------------------- -- Records of s_permission -- ---------------------------- INSERT INTO `s_permission` VALUES ('1', 'P_INDEX', '/index', 'index页面资源'); INSERT INTO `s_permission` VALUES ('2', 'P_ADMIN', '/admin', 'admin页面资源'); INSERT INTO `s_permission` VALUES ('3', 'P_HELLO', '/hello', 'hello页面资源');
好了,数据库表相关的内容是准备完成。
四、测试页面的准备
紧接着我们创建一些用来测试检验效果的页面:login.html、index、admin、hello 等页面,其中 login.html 是用来检验登录效果的,代码如下:
login.html
登录 INDEX | ADMIN | HELLO
已成功注销 有错误,请重试
使用用户名密码登录
index、admin、hello等页面内容都差不多,就是不同导航链接页面,到时候会用来测试权限控制的一些效果,其中 index.html 的内容如下:
index.html
主页 INDEX
你好: INDEX | ADMIN | HELLO
好了,页面也准备好了,接着下一步。
五、基础类及查询接口的创建
所需数据是准备好了,接下来我们要创建一系列的数据对象,和对应的查询接口,来供 Spring Security 使用,先来创建一波数据 bean 类:SUser、SRole、SPermission,这几个分别是用户、角色、权限资源类,代码如下:
SUser.java
package com.veiking.sec.bean; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * 用户名密码信息 * @author Veiking */ @Data @NoArgsConstructor @AllArgsConstructor public class SUser { private int id; private String name; private String password; public SUser(SUser sUser) { this.id = sUser.getId(); this.name = sUser.getName(); this.password = sUser.getPassword(); } }
SRole.java
package com.veiking.sec.bean; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * 角色信息 * @author Veiking */ @Data @NoArgsConstructor @AllArgsConstructor public class SRole { private int id; private String role; private String describe; }
SPermission.java
package com.veiking.sec.bean; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * 访问资源信息 * @author Veiking */ @Data @NoArgsConstructor @AllArgsConstructor public class SPermission { private int id; private String permission; private String url; private String describe; }
注意@Data、@NoArgsConstructor、@AllArgsConstructor这些注解,都是lombok帮助处理set/get和全参无参构造方法的,如果不喜欢,自行替换即可。
然后来处理查询接口,我们这里采用的是 mybatis 框架的方式,好了,创建几个对应的dao,代码如下:
SUserDao.java
package com.veiking.sec.dao; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; import com.veiking.sec.bean.SUser; /** * 用户信息查询 * @author Veiking */ @Mapper public interface SUserDao { /** * 根据用户名获取用户 * * @param name * @return */ @Select(value = " SELECT su.* FROM s_user su WHERE su.name = #{name} ") public SUser findSUserByName(String name); }
SRoleDao.java
package com.veiking.sec.dao; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; import com.veiking.sec.bean.SRole; /** * 角色信息查询 * @author Veiking */ @Mapper public interface SRoleDao { /** * 根据用户ID获取角色列表 * @param sUserId * @return */ @Select(value=" SELECT sr.* FROM s_role sr " + " LEFT JOIN s_user_role sur ON sr.id = sur.fk_role_id " + " LEFT JOIN s_user su ON sur.fk_user_id = su.id " + " WHERE su.id = #{sUserId} ") public ListfindSRoleListBySUserId(int sUserId); /** * 根据资源路径获取角色列表 * @param sPermissionUrl * @return */ @Select(value=" SELECT sr.* FROM s_role sr " + " LEFT JOIN s_role_permission srp ON sr.id = srp.fk_role_id " + " LEFT JOIN s_permission sp ON srp.fk_permission_id = sp.id " + " WHERE sp.url = #{sPermissionUrl} ") public List findSRoleListBySPermissionUrl(String sPermissionUrl); }
SPermissionDao.java
package com.veiking.sec.dao; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; import com.veiking.sec.bean.SPermission; /** * 资源权限信息查询 * @author Veiking */ @Mapper public interface SPermissionDao { /** * 根据用户ID获取资源权限列表 * @param sUserId * @return */ @Select(value=" SELECT * FROM s_permission sp " + " LEFT JOIN s_role_permission srp ON sp.id = srp.fk_permission_id " + " LEFT JOIN s_role sr ON srp.fk_role_id = sr.id " + " LEFT JOIN s_user_role sur ON sr.id = sur.fk_role_id " + " LEFT JOIN s_user su ON sur.fk_user_id = su.id " + " WHERE su.id = #{sUserId} ") public ListfindSPermissionListBySUserId(int sUserId); /** * 根据资源路径获取资源权限列表 * @param sPermissionUrl * @return */ @Select(value=" SELECT * FROM s_permission sp WHERE sp.url = #{sUserId} ") public List findSPermissionListBySPermissionUrl(String sPermissionUrl); }
请注意,这里的几个Dao查询接口是使用注解的方式实现谁,当然,一般mybatis框架通常使用的方式是dao接口+xml脚本,当然个人也是习惯用xml实现较为复杂逻辑的脚本,但是在相对简单逻辑的操作上,直接用注解的方式是清爽的不能再清爽;两者在实际运用中是等效的,也是可以一同使用。
这几个接口的主要作用是:通过用户名(登录名)来获取用户信息;通过用户ID、资源路径(请求路径)来获取角色列表和权限资源列表。紧接着,本着编程习惯,我们再搞一个服务接口,将上边几个dao的功能整合,统一对外提供数据服务:
SecurityDataService.java
package com.veiking.sec.service; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.veiking.sec.bean.SPermission; import com.veiking.sec.bean.SRole; import com.veiking.sec.bean.SUser; import com.veiking.sec.dao.SPermissionDao; import com.veiking.sec.dao.SRoleDao; import com.veiking.sec.dao.SUserDao; /** * Security 数据服务 * * @author Veiking */ @Service public class SecurityDataService { @Autowired private SUserDao sUserDao; @Autowired private SRoleDao sRoleDao; @Autowired private SPermissionDao sPermissionDao; public SUser findSUserByName(String name) { return sUserDao.findSUserByName(name); } public ListfindSRoleListBySUserId(int sUserId) { return sRoleDao.findSRoleListBySUserId(sUserId); } public List findSRoleListBySPermissionUrl(String sPermissionUrl) { return sRoleDao.findSRoleListBySPermissionUrl(sPermissionUrl); } public List findSPermissionListBySUserId(int sUserId) { return sPermissionDao.findSPermissionListBySUserId(sUserId); } public List findSPermissionListBySPermissionUrl(String sPermissionUrl) { return sPermissionDao.findSPermissionListBySPermissionUrl(sPermissionUrl); } }
这个service没有额外的操作,仅仅是传递dao的功能,OK,到此,Spring Security 需要用的数据服务等一些准备部分,我们都已经准备好了,下面的环节,就是重点了。
六、重点:Spring Security之用户认证
经过一番相当罗嗦的铺垫,终于迎来了正题,我们将在接下来的环节里,讲述 Spring Security 认证有关的东西。
首先,再次回顾,Spring Security 认证有关的重要类或接口:UserDetails、UserDetailsService、AuthenticationProvider,我们将尝试自定义封装UserDetails,经由UserDetailsService提供给AuthenticationProvider,然后和请求消息中获取的用户信息进行对比认证。
首先,为了刻意的来区分认证和鉴权这里啊范畴,我们先来卖个关子,在包主路径下创建俩包:authentication、authorization,这俩单词简直是很像了,也是特意才用这两个单词,是看到有位前辈在博客中调侃了他们,印象深刻:authentication即认证,authorization即鉴权,注意字母微小的差异下在逻辑实现中不同的含义。
好,在authentication包下来完成我们 Spring Security 的认证,先新建一个 VUserDetails 类来实现 UserDetails(注:在此,所有的重新实现,都将在原类或接口名称前缀加大写的V,此处仅为示例,如有仿例操作,请根据个人习惯;包括之前的类或接口名,也不是很符合java推荐的命名规则,这只是为了在名称上强调而强调,勿在意,更勿仿效),代码如下:
VUserDetails.java
package com.veiking.sec.authentication; import java.util.Collection; import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.UserDetails; import com.google.gson.Gson; import com.veiking.sec.bean.SPermission; import com.veiking.sec.bean.SRole; import com.veiking.sec.bean.SUser; /** * 用户信息的封装,包含用户名称密码及用户状态、权限等信息 * @author Veiking */ public class VUserDetails extends SUser implements UserDetails{ private static final long serialVersionUID = 1L; Gson gson = new Gson(); Logger logger = LoggerFactory.getLogger(this.getClass()); //用户角色列表 private ListsRoleList = null; //用户资源权限列表 private List sPermissionList = null; /** * 注意后边的这两个参数:sRoleList、sPermissionList * @param sUser * @param sRoleList * @param sPermissionList */ public VUserDetails(SUser sUser, List sRoleList, List sPermissionList) { super(sUser); this.sRoleList = sRoleList; this.sPermissionList = sPermissionList; } /** * 获取用户权限列表方法 * 可以理解成,返回了一个List ,之后所谓的权限控制、鉴权,其实就是跟这个list里的String进行对比 * 这里处理了角色和资源权限两个列表,可以这么理解, * 角色是权限的抽象集合,是为了更方便的控制和分配权限,而真正颗粒化细节方面,还是需要资源权限自己来做 */ @Override public Collection extends GrantedAuthority> getAuthorities() { StringBuilder authoritiesBuilder = new StringBuilder(""); List tempRoleList = this.getsRoleList(); if (null != tempRoleList) { for (SRole role : tempRoleList) { authoritiesBuilder.append(",").append(role.getRole()); } } List tempPermissionList = this.getsPermissionList(); if (null != tempPermissionList) { for (SPermission permission : tempPermissionList) { authoritiesBuilder.append(",").append(permission.getPermission()); } } String authoritiesStr = ""; if(authoritiesBuilder.length()>0) { authoritiesStr = authoritiesBuilder.deleteCharAt(0).toString(); } logger.info("VUserDetails getAuthorities [authoritiesStr={} ", authoritiesStr); return AuthorityUtils.commaSeparatedStringToAuthorityList(authoritiesStr); } @Override public String getPassword() { return super.getPassword(); } @Override public String getUsername() { return super.getName(); } /** * 判断账号是否已经过期,默认没有过期 */ @Override public boolean isAccountNonExpired() { // TODO Auto-generated method stub return true; } /** * 判断账号是否被锁定,默认没有锁定 */ @Override public boolean isAccountNonLocked() { return true; } /** * 判断信用凭证是否过期,默认没有过期 */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 判断账号是否可用,默认可用 */ @Override public boolean isEnabled() { return true; } public List getsRoleList() { return sRoleList; } public void setsRoleList(List sRoleList) { this.sRoleList = sRoleList; } public List getsPermissionList() { return sPermissionList; } public void setsPermissionList(List sPermissionList) { this.sPermissionList = sPermissionList; } }
注意这个VUserDetails,它继承SUser并实现了UserDetails,这个类主要功能就是封装用户信息,包括从SUser继承来的用户名密码等属性,还有两个角色和权限的列表,注意这个 getAuthorities(),这个方法主要工作是提供一组框架定义的权限列表,可以留意下源码,这个并没有定义具体类型,我们这里就用String类型实现这个权限。
这里还要解释下,我们在getAuthorities方法里里分别循环了两个列表来加工 Spring Security 需要权限信息,即 tempRoleList 和 tempPermissionList,可以这样子理解,角色和权限的概念,角色本身是权限的抽象集合,是协助我们开发管理的东西,真正意义的东西还是颗粒细小的权限。添个插曲,在本人最初接触到权限设计的时候,总是傻傻的被二者的关系搞晕,加上一些实际应用的系统还乐此不疲的在权限命名上"ROLE"来"ROLE"去的,甚至一些方法命名本身也在混淆这二者(怀疑可能是英语的使用习惯之类的原因),导致早先的我常常常常陷入对二者的理解困惑上,当然现在清晰的很多: 在大块儿整体性的权限控制上,角色控制为主;细化到页面小块儿、按钮级别的,权限控制为主;一般再加上访问URL的过滤鉴权,基本上一套强壮的权限控制体系是稳稳的在这儿了。
最后注意下代码里的几个isXXX方法,这些是一些细节补充,一般默认,也可以重写控制下逻辑;紧接着我们新建一个 VUserDetailsService 类,来实现UserDetailsService,代码如下:
VUserDetailsService.java
package com.veiking.sec.authentication; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import com.veiking.sec.bean.SPermission; import com.veiking.sec.bean.SRole; import com.veiking.sec.bean.SUser; import com.veiking.sec.service.SecurityDataService; /** * 提供用户信息封装服务 * @author Veiking */ @Service public class VUserDetailsService implements UserDetailsService { @Autowired SecurityDataService securityDataService; /** * 根据用户输入的用户名返回数据源中用户信息的封装,返回一个UserDetails */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SUser sUser = securityDataService.findSUserByName(username); //用户角色列表 ListsRoleList = securityDataService.findSRoleListBySUserId(sUser.getId()); //用户资源权限列表 List sPermissionList = securityDataService.findSPermissionListBySUserId(sUser.getId()); return new VUserDetails(sUser, sRoleList, sPermissionList); } }
这个类基本上没啥好说的,服务提供者,就是一个搬运工,看这个loadUserByUsername()方法,拿到用户基本信息、角色列表和资源权限列表后,构造一个 VUserDetails 对象,OK返回。接下来是一个小重点,我们创建一个 VAuthenticationProvider 类来实现 AuthenticationProvider,代码如下:
VAuthenticationProvider.java
package com.veiking.sec.authentication; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.stereotype.Component; import com.google.gson.Gson; /** * 认证提供者,校验用户,登录名密码,以及向系统提供一个用户信息的综合封装 * @author Veiking */ @Component public class VAuthenticationProvider implements AuthenticationProvider { Gson gson = new Gson(); Logger logger = LoggerFactory.getLogger(this.getClass()); @Autowired VUserDetailsService vUserDetailsService; /** * 首先,在用户登录的时候,系统将用户输入的的用户名和密码封装成一个Authentication对象 * 然后,根据用户名去数据源中查找用户的数据,这个数据是封装成的VUserDetails对象 * 接着,将两个对象进行信息比对,如果密码正确,通过校验认证 * 最后,将用户信息(含身份信息、细节信息、密码、权限等)封装成一个对象,此处参考UsernamePasswordAuthenticationToken * 最最后,会将这个对象交给系统SecurityContextHolder中(功能类似Session),以便后期随时取用 */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username = authentication.getName(); String password = authentication.getCredentials().toString(); logger.info("VAuthenticationProvider authenticate login user [username={}, password={}]", username, password); VUserDetails vUserDetails = (VUserDetails)vUserDetailsService.loadUserByUsername(username); logger.info("VAuthenticationProvider authenticate vUserDetails [vUserDetails={}]", gson.toJson(vUserDetails)); if(vUserDetails == null){ throw new BadCredentialsException("用户没有找到"); } if (!password.equals(vUserDetails.getPassword())) { logger.info("VAuthenticationProvider authenticate BadCredentialsException [inputPassword={}, DBPassword={}]", password, vUserDetails.getPassword()); throw new BadCredentialsException("密码错误"); } //认证校验通过后,封装UsernamePasswordAuthenticationToken返回 return new UsernamePasswordAuthenticationToken(vUserDetails, password, vUserDetails.getAuthorities()); } @Override public boolean supports(Class> authentication) { return true; } }
这个实现类的核心就是authenticate方法,一步步看,系统会将用户在登录请求操作的时候,把输入的用户名密码等,封装到一个Authentication对象中,我们从这个对象里拿到用户名,通过 VUserDetailsService 获取到 VUserDetails 对象,然后拿这个对象的密码属性,和请求Authentication对象中获取的密码进行对比,如果一切OK,验证功过,然后再将这些所有信息,整体封装成一个Authentication对象(这里边我们用的是UsernamePasswordAuthenticationToken),交给系统框架,后期可以随时取用。
好了,经过上面的工作,用户认证的逻辑已经完事儿了,我们要做访问工作,这里还要做些配置操作,这里分别新建俩类,代码如下:
WebMvcConfig.java
package com.veiking.sec; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * 访问路径配置类 * 可以理解成做简单访问过滤的,转发到相应的视图页面 * @author Veiking */ @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/login").setViewName("login"); registry.addViewController("/").setViewName("index"); registry.addViewController("/index").setViewName("index"); } }
PageController.java
package com.veiking.sec.controller; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; /** * 请求页面分发,注意和WebMvcConfig的对比,功能类似 * @author Veiking */ @Controller public class PageController { @RequestMapping("/admin") public String admin(Model model, String tt) { return "admin"; } @RequestMapping("/hello") public String hello(Model model, String tt) { return "hello"; } }
WebMvcConfig 是一个简单的路径映射,功能跟在 PageController中实现的差不多,之所以多写一个PageController,是因为后边会有其他的功能演示。
然后我们还需创建一个 WebSecurityConfig 类来继承 WebSecurityConfigurerAdapter,代码如下:
WebSecurityConfig.java
package com.veiking.sec; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; /** * Security 主配置文件 * @author Veiking */ @Configuration @EnableWebSecurity //开启Spring Security的功能 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { /** * 定义不需要过滤的静态资源(等价于HttpSecurity的permitAll) */ @Override public void configure(WebSecurity webSecurity) throws Exception { webSecurity.ignoring().antMatchers("/css/**"); } /** * 安全策略配置 */ @Override protected void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity .authorizeRequests() // 对于网站部分资源需要指定鉴权 //.antMatchers("/admin/**").hasRole("ADMIN") // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated().and() // 定义当需要用户登录时候,转到的登录页面 .formLogin().loginPage("/login").defaultSuccessUrl("/index").permitAll().and() // 定义登出操作 .logout().logoutSuccessUrl("/login?logout").permitAll().and() .csrf().disable() ; // 禁用缓存 httpSecurity.headers().cacheControl(); } }
这个类是使用 Spring Security 的主配置入口,在这个配置文件中,正式启用 Spring Security 包括我们之前所讲的所有功能,这里主要留意一下负责安全策略配置的 configure()方法,这个方法里可以定义登录登出等操作细节,以及一些静态资源的权限忽略之类的,甚至也是可以直接手动配权限的。
一切完事儿,我们运行 SecApplication ,开始验证之旅:
在登录页面,输入用户名密码:admin/admin,登录看看,随便点点跳跳,换成veiking/veiking试试,也可以输错试试,再试下登出:
好了,这个简单的用户认证功能看来是可以了,我们接下来看看如何控制权限。
七、重点:Spring Security之鉴权-初试
认证OK,回想下,是否还记得,在VAuthenticationProvider的校验环节,我们在封装返回给系统的Authentication对象里,是提供了vUserDetails.getAuthorities()这个认证列表的,接下来看看,这个被交给系统的认证列表,是怎么体现的。
我们打开 hello.html 页面,在其中的几个导航跳转的信息上,添加一个 sec:authorize="hasAuthority('XXX')" 的代码,这样子的脚本,大概意思就是,只有名为‘XXX’的角色或者权限的用户,登录之后才可以看到,如下:
hello.html
HELLO HELLO
你好: INDEX | ADMIN | HELLO
(注意,在页面中使用 Spring Security 相关脚本,要在标签处添加 xmlns:th="http://www.thymeleaf.org" 、 xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4" 等约束规范)重新启动后,分别用不同的用户,登录后跳转到hello页面查看,这时候可以看到,admin用户拥有较多权限,都可以看到, veiking 用户只能看到index和hello导航,而 xiaoming 用户什么都看不到了,并且他们都不能看到注销按钮,就是这个效果:[/size]
上边是从页面层面来进行权限控制的,注意hasAuthority('XXX')中,有用到R_ADMIN、P_ADMIN、P_HELLO不同类型的权限字眼,包含角色和权限,这个控制的颗粒度没有绝对的,只要设计成规律可循、操作可行方案即可。
接下来,打开 PageController,在/admin处添加标签:@PreAuthorize("hasAuthority('R_ADMIN')"),如下:
PageController.java
package com.veiking.sec.controller; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; /** * 请求页面分发,注意和WebMvcConfig的对比,功能类似 * @author Veiking */ @Controller public class PageController { @RequestMapping("/admin") @PreAuthorize("hasAuthority('R_ADMIN')") public String admin(Model model, String tt) { return "admin"; } @RequestMapping("/hello") public String hello(Model model, String tt) { return "hello"; } }
注意,这个操作还需要在 WebSecurityConfig 类中加 @EnableGlobalMethodSecurity(prePostEnabled=true) 标签来,开启注解控制权限,然后配置 authenticationManagerBean 以供支持,代码如下:
WebSecurityConfig.java
package com.veiking.sec; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; /** * Security 主配置文件 * @author Veiking */ @Configuration @EnableWebSecurity //开启Spring Security的功能 @EnableGlobalMethodSecurity(prePostEnabled=true)//开启注解控制权限 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { /** * 定义不需要过滤的静态资源(等价于HttpSecurity的permitAll) */ @Override public void configure(WebSecurity webSecurity) throws Exception { webSecurity.ignoring().antMatchers("/css/**"); } /** * 安全策略配置 */ @Override protected void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity .authorizeRequests() // 对于网站部分资源需要指定鉴权 //.antMatchers("/admin/**").hasRole("ADMIN") // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated().and() // 定义当需要用户登录时候,转到的登录页面 .formLogin().loginPage("/login").defaultSuccessUrl("/index").permitAll().and() // 定义登出操作 .logout().logoutSuccessUrl("/login?logout").permitAll().and() .csrf().disable() ; // 禁用缓存 httpSecurity.headers().cacheControl(); } /** * 开启注解控制权限 * 见Controller 中 @PreAuthorize("hasAuthority('XXX')") */ @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
然后再次启动,用veiking登录,INDEX页面点击ADMIN导航 ——好,403 Forbidden了,对,被拦了,就是这个效果。
以上这些,是简单的对 Spring Security 鉴权操作的一些尝试,当然,如果是小规模功能开发,这些是可以满足的,如果想追求更为灵活的控制,就要重新是实现下过滤机制,接下来我们就尝试下从对数据库层面的配置,实现权限的动态管理。
八、重点:Spring Security之鉴权-过滤器
上边我们已尝试了经通过页面脚本和注解这两种方式的权限控制,接下来,我们尝试下通过数据库的权限配置,来过滤用户操作请求的。
跟认证对应,我们新建一个包,authorization,然后在这个里面来实现过滤请求方式的鉴权:先写一个 VFilterInvocationSecurityMetadataSource 类,来实现 FilterInvocationSecurityMetadataSource,这个可以简单理解成权限资源管理器,它的工作是通过用户的请求地址,来获取访问这个地址所需的权限,代码如下:
FilterInvocationSecurityMetadataSource.java
package com.veiking.sec.authorization; import java.util.ArrayList; import java.util.Collection; import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.ConfigAttribute; import org.springframework.security.access.SecurityConfig; import org.springframework.security.web.FilterInvocation; import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource; import org.springframework.stereotype.Component; import com.google.gson.Gson; import com.veiking.sec.bean.SPermission; import com.veiking.sec.bean.SRole; import com.veiking.sec.service.SecurityDataService; /** * 权限资源管理器 * 根据用户请求的地址,获取访问该地址需要的所需权限 * @author Veiking */ @Component public class VFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { Gson gson = new Gson(); Logger logger = LoggerFactory.getLogger(this.getClass()); @Autowired SecurityDataService securityDataService; @Override public CollectiongetAttributes(Object object) throws IllegalArgumentException { //获取请求起源路径 String requestUrl = ((FilterInvocation) object).getRequestUrl(); logger.info("VFilterInvocationSecurityMetadataSource getAttributes [requestUrl={}]", requestUrl); //登录页面就不需要权限 if ("/login".equals(requestUrl)) { return null; } //用来存储访问路径需要的角色或权限信息 List tempPermissionList = new ArrayList (); //获取角色列表 List sRoleList = securityDataService.findSRoleListBySPermissionUrl(requestUrl); logger.info("VFilterInvocationSecurityMetadataSource getAttributes [sRoleList={}]", gson.toJson(sRoleList)); for(SRole sRole : sRoleList) { tempPermissionList.add(sRole.getRole()); } //径获取资源权限列表 List sPermissionList = securityDataService.findSPermissionListBySPermissionUrl(requestUrl); logger.info("VFilterInvocationSecurityMetadataSource getAttributes [sPermissionList={}]", gson.toJson(sPermissionList)); for(SPermission sPermission : sPermissionList) { tempPermissionList.add(sPermission.getPermission()); } //如果没有权限控制的url,可以设置都可以访问,也可以设置默认不许访问 if(tempPermissionList.isEmpty()) { return null;//都可以访问 //tempPermissionList.add("DEFAULT_FORBIDDEN");//默认禁止 } String[] permissionArray = tempPermissionList.toArray(new String[0]); logger.info("VFilterInvocationSecurityMetadataSource getAttributes [permissionArray={}]", gson.toJson(permissionArray)); return SecurityConfig.createList(permissionArray); } @Override public Collection getAllConfigAttributes() { // TODO Auto-generated method stub return null; } @Override public boolean supports(Class> clazz) { return true; } }
接着访问需要的权限资源拿到了,就得有判断的地方,新建一个 VAccessDecisionManager 来实现 AccessDecisionManager ,这个类可以理解成权限管理判断器,他的主要工作就是鉴权,通过拿到的访问路径所需的权限,和用户所拥有的权限进行对比,判断用户是否有权限访问,代码如下:
VAccessDecisionManager.java
package com.veiking.sec.authorization; import java.util.Collection; import org.springframework.security.access.AccessDecisionManager; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.ConfigAttribute; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; /** * 权限管理判断器|校验用户是否有权限访问请求资源 * @author Veiking */ @Component public class VAccessDecisionManager implements AccessDecisionManager { @Override public void decide(Authentication authentication, Object object, CollectionconfigAttributes) throws AccessDeniedException, InsufficientAuthenticationException { //当前用户所具有的权限 Collection extends GrantedAuthority> userAuthorityList = authentication.getAuthorities(); //访问资源所需的权限信息 Collection needAuthoritieList = configAttributes; //依次循环对比,发现有匹配的即返回 for(ConfigAttribute needAuthoritie: needAuthoritieList) { String needAuthoritieStr = needAuthoritie.getAttribute(); for (GrantedAuthority userAuthority : userAuthorityList) { String userAuthorityStr = userAuthority.getAuthority(); if (needAuthoritieStr.equals(userAuthorityStr)) { return; } } } //执行到这里说明没有匹配到应有权限 throw new AccessDeniedException("权限不足!"); } @Override public boolean supports(ConfigAttribute attribute) { return true; } @Override public boolean supports(Class> clazz) { return true; } }
最后,要写一个过滤器,提供上边这些功能的工作场所,创建 VFilterSecurityInterceptor 类,继承 AbstractSecurityInterceptor 并实现 Filter,这就是个鉴权过滤器,代码如下:
VFilterSecurityInterceptor.java
package com.veiking.sec.authorization; import java.io.IOException; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.annotation.WebFilter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.servlet.ServletComponentScan; import org.springframework.security.access.SecurityMetadataSource; import org.springframework.security.access.intercept.AbstractSecurityInterceptor; import org.springframework.security.access.intercept.InterceptorStatusToken; import org.springframework.security.web.FilterInvocation; import org.springframework.stereotype.Component; /** * 访问鉴权过滤器 * 该过滤器的作用就是,用户请求时,提供权限资源管理器和权限判断器工作的场所,实现鉴权操作 * @author Veiking */ @Component @ServletComponentScan @WebFilter(filterName="vFilterSecurityInterceptor",urlPatterns="/*") public class VFilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter { @Autowired private VFilterInvocationSecurityMetadataSource vFilterInvocationSecurityMetadataSource; @Autowired public void setMyAccessDecisionManager(VAccessDecisionManager vAccessDecisionManager) { super.setAccessDecisionManager(vAccessDecisionManager); } @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { FilterInvocation filterInvocation = new FilterInvocation(request, response, chain); invoke(filterInvocation); } public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException { // filterInvocation里面有一个被拦截的url // 里面调用VFilterInvocationSecurityMetadataSource的getAttributes(Object object)这个方法获取filterInvocation对应的所有权限 // 再调用VAccessDecisionManager的decide方法来校验用户的权限是否足够 InterceptorStatusToken interceptorStatusToken = super.beforeInvocation(filterInvocation); try { // 执行下一个拦截器 filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse()); } finally { super.afterInvocation(interceptorStatusToken, null); } } @Override public void destroy() { } @Override public Class> getSecureObjectClass() { return FilterInvocation.class; } @Override public SecurityMetadataSource obtainSecurityMetadataSource() { return this.vFilterInvocationSecurityMetadataSource; } }
这里边注意 @ServletComponentScan 和 @WebFilter(filterName="vFilterSecurityInterceptor",urlPatterns="/*") 注解,这个是确保过滤器自动注册并工作,否则过滤器无效。
接下来启动项目,用个个用户登进去看看,尤其是veiking和xiaoming用户,访问没有权限的连接时果断遭到限制,403 Forbidden!
九、结语
好了,经过这么一番折腾,学习应用 Spring Security 框架该接触到的一些知识点,都有所体现了。权限控制的本质,就是对比校验,其一般体现方式,就是过滤器。Spring Security 是提供了一种相对比较好的表现形式,给出了一个优良的范式,敲示例代码的本身,更重要的应该是为了帮助理解和学习,而不是为了实现而实现。
本文是足够罗嗦,也是个人为了加深在理解记忆,但有些地方甚至也是错误的、不合乎规范的,希望大家不要被误导,这只能说是一个提供理解的参考,帮助大家从懵懂到懂;还有需要注意的是,因spring版本不同导致的一些细节差异,可能会有小坑,还是需要注意下的。文中所涉及代码最后都在附件中,感兴趣的同学可以自行下载。
还有,喜欢的,扫下支付宝家电红包吧,哈哈哈!
代码附件:
http://dl2.iteye.com/upload/attachment/0130/5515/3b1eeefa-fa64-3dd2-97c0-753bd96c1acc.rar