RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。模型中有几个关键的术语:
RBAC权限模型核心授权逻辑如下:
想到权限控制,人们最先想到的一定是用户与权限直接关联的模式,简单地说就是:某个用户具有某些权限。如图:
这种模型能够清晰的表达用户与权限之间的关系,足够简单。但同时也存在问题:
在实际的团体业务中,都可以将用户分类。比如对于薪水管理系统,通常按照级别分类:经理、高级工程师、中级工程师、初级工程师。也就是按照一定的角色分类,通常具有同一角色的用户具有相同的权限。这样改变之后,就可以将针对用户赋权转换为针对角色赋权。因为角色少、权限多,所以基于角色管理权限,减少用户在授权与权限回收过程中的过多操作。
我们可以用下图中的数据库设计模型,描述这样的关系。
维护一对多的关系的话:一般相关的外键,建立在多的一方
下面的案例是:用户有很多,但是角色就这么几个,所以将外键关联方在用户表中【也就1对多,多的一方】
但是在实际的应用系统中,一个用户一个角色远远满足不了需求。如果我们希望一个用户既担任销售角色、又暂时担任副总角色。该怎么做呢?为了增加系统设计的适用性,我们通常设计:
我们可以用下图中的数据库设计模型,描述这样的关系。
数据权限比较好理解,就是某个用户能够访问和操作哪些数据。
所以为了面对复杂的需求,数据权限的控制通常是由程序员书写个性化的SQL来限制数据范围的,而不是交给权限模型或者Spring Security或shiro来控制。当然也可以从权限模型或者权限框架的角度去解决这个问题,但适用性有限。
上图中:
本文讲解只将权限控制到菜单的访问级别,即控制页面的访问权限。如果想控制到页面中按钮级别的访问,可以参考Menu与RoleMenu的模式同样的实现方式。或者干脆在menu表里面加上一个字段区别该条记录是菜单项还是按钮。
为了能够给大家把功能的需求讲明白,我们参考我自己开发的一个开源项目:dongbb。
演示地址:http://123.56.169.21/dongbb/
演示环境用户密码:admin/Abcd1234
大家可以一边看系统,一边通过下文来理解RBAC权限管理模型的实现。
请大家爱惜演示环境,自己创建的数据自己删除、修改。不要去删除修改他人创建的数据。如果多次删改“权限数据”造成演示环境,无法使用的情况,我将采取禁用权限的手段,大家的可操作空间将会缩小。
之所以先将组织部门管理提出来讲一下,是因为组织管理没有在我们上面的RBAC权限模型中进行体现。但是“组织”这样一个实体仍然是,后端管理系统的一个重要组成部分。通常有如下的需求:
以下SQL以MySQL为例:
CREATE TABLE `sys_org` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`org_pid` INT(11) NOT NULL COMMENT '上级组织编码',
`org_pids` VARCHAR(64) NOT NULL COMMENT '所有的父节点id',
`is_leaf` TINYINT(4) NOT NULL COMMENT '0:不是叶子节点,1:是叶子节点',
`org_name` VARCHAR(32) NOT NULL COMMENT '组织名',
`address` VARCHAR(64) NULL DEFAULT NULL COMMENT '地址',
`phone` VARCHAR(13) NULL DEFAULT NULL COMMENT '电话',
`email` VARCHAR(32) NULL DEFAULT NULL COMMENT '邮件',
`sort` TINYINT(4) NULL DEFAULT NULL COMMENT '排序',
`level` TINYINT(4) NOT NULL COMMENT '组织层级',
`status` TINYINT(4) NOT NULL COMMENT '0:启用,1:禁用',
PRIMARY KEY (`id`)
)
COMMENT='系统组织结构表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB;
注意:mysql没有oracle中的start with connect by的树形数据汇总SQL。所以通常需要为了方便管理组织之间的上下级树形关系,需要加上一些特殊字段,如:org_pids:该组织所有上级组织id逗号分隔,即包括上级的上级;is_leaf是否是叶子结点;level组织所属的层级(1,2,3)。
CREATE TABLE `sys_menu` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`menu_pid` INT(11) NOT NULL COMMENT '父菜单ID',
`menu_pids` VARCHAR(64) NOT NULL COMMENT '当前菜单所有父菜单',
`is_leaf` TINYINT(4) NOT NULL COMMENT '0:不是叶子节点,1:是叶子节点',
`menu_name` VARCHAR(16) NOT NULL COMMENT '菜单名称',
`url` VARCHAR(64) NULL DEFAULT NULL COMMENT '跳转URL',
`icon` VARCHAR(45) NULL DEFAULT NULL,
`icon_color` VARCHAR(16) NULL DEFAULT NULL,
`sort` TINYINT(4) NULL DEFAULT NULL COMMENT '排序',
`level` TINYINT(4) NOT NULL COMMENT '菜单层级',
`status` TINYINT(4) NOT NULL COMMENT '0:启用,1:禁用',
PRIMARY KEY (`id`)
)
COMMENT='系统菜单表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB;
上图为角色修改及分配权限的页面
CREATE TABLE `sys_role` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`role_name` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '角色名称(汉字)',
`role_desc` VARCHAR(128) NOT NULL DEFAULT '0' COMMENT '角色描述',
`role_code` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '角色的英文code.如:ADMIN',
`sort` INT(11) NOT NULL DEFAULT '0' COMMENT '角色顺序',
`status` INT(11) NULL DEFAULT NULL COMMENT '0表示可用',
`create_time` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '角色的创建日期',
PRIMARY KEY (`id`)
)
COMMENT='系统角色表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB;
CREATE TABLE `sys_role_menu` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`role_id` INT(11) NOT NULL DEFAULT '0' COMMENT '角色id',
`menu_id` INT(11) NOT NULL DEFAULT '0' COMMENT '权限id',
PRIMARY KEY (`id`)
)
COMMENT='角色权限关系表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB;
CREATE TABLE `sys_user` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NOT NULL DEFAULT '0' COMMENT '用户名',
`password` VARCHAR(64) NOT NULL DEFAULT '0' COMMENT '密码',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
`org_id` INT(11) NOT NULL COMMENT '组织id',
`enabled` INT(11) NULL DEFAULT NULL COMMENT '0无效用户,1是有效用户',
`phone` VARCHAR(16) NULL DEFAULT NULL COMMENT '手机号',
`email` VARCHAR(32) NULL DEFAULT NULL COMMENT 'email',
PRIMARY KEY (`id`)
)
COMMENT='用户信息表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB;
CREATE TABLE `sys_user_role` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`role_id` INT(11) NOT NULL DEFAULT '0' COMMENT '角色自增id',
`user_id` INT(11) NOT NULL DEFAULT '0' COMMENT '用户自增id',
PRIMARY KEY (`id`)
)
COMMENT='用户角色关系表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB;
在用户的信息表中,体现了一些隐藏的需求。如:多次登录锁定与锁定到期时间的关系。账号有效期的设定规则等。
当然用户表中,根据业务的不同还可能加更多的信息,比如:用户头像等等。但是通常在比较大型的业务系统开发中,业务模块中使用的用户表和在权限管理模块使用的用户表通常不是一个,而是根据某些唯一字段弱关联,分开存放。这样做的好处在于:经常发生变化的业务需求,不会去影响不经常变化的权限模型。
在本号之前的文章中,已经介绍了Spring Security的formLogin登录认证模式,RBAC的权限控制管理模型,并且针对Spring Security的登录认证逻辑源码进行了解析等等。
我们所有的用户、角色、权限信息都是在配置文件里面写死的,然而在实际的业务系统中,这些信息通常是存放在RBAC权限模型的数据库表中的。我们本节的内容就是,把这些信息从数据库里面进行加载。
下面我们来回顾一下其中的核心概念:
以上是对一些核心的基础知识的总结,如果您对这些知识还不是很清晰,建议您先往下读本文。如果看完本文仍然理解困难,建议您翻看之前的文章。
在UserDetails中设置我们的用户相关数据
在UserDetailsService中获取提供给springsecurity数据
下面我们来看一下UserDetails接口都有哪些方法。
public interface UserDetails extends Serializable {
//获取用户的权限集合
Collection<? extends GrantedAuthority> getAuthorities();
//获取密码
String getPassword();
//获取用户名
String getUsername();
//账号是否没过期
boolean isAccountNonExpired();
//账号是否没被锁定
boolean isAccountNonLocked();
//密码是否没过期
boolean isCredentialsNonExpired();
//账户是否可用
boolean isEnabled();
}
现在我们明白了,只要我们把这些信息提供给Spring Security,Spring Security就知道怎么做登录验证了,根本不需要我们自己写Controller实现登录验证逻辑。那我们怎么把这些信息提供给Spring Security,用的就是下面的接口方法。
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
我们提供set()方法对应的成员变量值,并返回我们定义的成员变量,springsecurity来提供get()方法
public class MyUserDetails implements UserDetails {
//定义下面的成员变量值
String password; //密码
String username; //用户名
boolean accountNonExpired; //是否没过期
boolean accountNonLocked; //是否没被锁定
boolean credentialsNonExpired; //密码是否没过期
boolean enabled; //账号是否可用
Collection<? extends GrantedAuthority> authorities; //用户的权限集合
=============================================================
//我们再提供set()方法
public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
}
public void setPassword(String password) {
this.password = password;
}
public void setUsername(String username) {
this.username = username;
}
public void setAccountNonExpired(boolean accountNonExpired) {
this.accountNonExpired = accountNonExpired;
}
public void setAccountNonLocked(boolean accountNonLocked) {
this.accountNonLocked = accountNonLocked;
}
public void setCredentialsNonExpired(boolean credentialsNonExpired) {
this.credentialsNonExpired = credentialsNonExpired;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
===================================================================
//springsecurity提供get()方法获取我们提供的数据
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
//这里我数据库就使用enabled字段,是否可用,其他就直接返回true
//如果你要使用可以返回对应的成员变量值
@Override
public boolean isAccountNonExpired() {
return true;
//return isAccountNonExpired;
}
//这里我数据库就使用enabled字段,是否可用,其他就直接返回true
//如果你要使用可以返回对应的成员变量值
@Override
public boolean isAccountNonLocked() {
return true;
//return isAccountNonLocked;
}
//这里我数据库就使用enabled字段,是否可用,其他就直接返回true
//如果你要使用可以返回对应的成员变量值
@Override
public boolean isCredentialsNonExpired() {
return true;
//return isCredentialsNonExpired;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
我们就是写了一个适应于UserDetails的java POJO类,所谓的 UserDetails接口实现就是一些get方法。
目前数据库表里面
没有定义accountNonExpired、accountNonLocked、credentialsNonExpired这三个字段
,我一般不喜欢搞这么多字段控制用户的登录认证行为,笔者觉得简单点好,一个enabled字段就够了
。所以这三个成员变量对应的get方法,直接返回true即可。(后续章节实现《多次登陆失败账户锁定功能》的时候,我们用到了accountNonLocked,到时候我们再到数据库里面添加字段)
在UserDetailsService中通过持久化相关的框架或什么方式,获取到数据库中的数据,并封装成上面的UserDetails对象返回给springsecurity提供数据即可
@Component
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private MyUserDetailServiceMapper myUserDetailServiceMapper;
//方法的参数(String username),代表这个用户的唯一标识
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//用户基本信息
MyUserDetails userDetails = myUserDetailServiceMapper.findByUserName(username);
if (userDetails==null){
throw new UsernameNotFoundException("用户名不存在");
}
//用户角色列表
List<String> roleList = myUserDetailServiceMapper.findRoleByUserName(username);
//用户菜单访问权限
List<String> menuList = myUserDetailServiceMapper.findMenuByRole(roleList);
//需要对每一个角色列表中的每一项前面加权限的表示 "ROLE_" ,如ROLE_admin 指一个权限叫admin
roleList = roleList.stream().map(item -> "ROLE_" + item).collect(Collectors.toList());
menuList.addAll(roleList);
//如AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_ADMIN,ROLE_USER"))
//setAuthorities():为用户分配权限
//AuthorityUtils.commaSeparatedStringToAuthorityList():通过逗号分隔开,并读取里面的权限词
// String.join:参数1:以什么分隔 参数2:数据源,下面的的方法就是以逗号分隔开menuList集合中的元素
userDetails.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(
String.join(",",menuList)
));
return userDetails;
}
}
重写WebSecurityConfigurerAdapter的 configure(AuthenticationManagerBuilder auth)方法
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailsService myUserDetailsService;
//登录认证及资源访问权限的控制
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()//开启formLogin认证
.loginPage("/login.html")
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.and()
.authorizeRequests()
.antMatchers("/login.html","/login").permitAll()//都可访问
.antMatchers("/","/biz1","biz2")
.hasAnyAuthority("ROLE_common","ROLE_admin")
.antMatchers("/syslog","/sysuser")
.hasAnyRole("admin")//只要你是admin角色可以访问:"/syslog","/sysuser"
.antMatchers("/syslog").hasAuthority("/syslog")
.antMatchers("/sysuser").hasAuthority("/sysuser")
.anyRequest().authenticated();
}
//用户及角色信息配置
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService) //使用我们自己配置动态的从数据库获取对应的权限信息数据
.passwordEncoder(passwordEncoder());//配置使用BCrypt加密
}
//注入BCrypt加密
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
使用BCryptPasswordEncoder,表示存储中(数据库)取出的密码必须是经过BCrypt加密算法加密的。
这里需要注意的是,因为我们使用了BCryptPasswordEncoder加密解密,所以数据库表里面存的密码应该是加密之后的密码(造数据的过程),可以使用如下代码加密(如密码是:123456)。将打印结果保存保存到密码字段。
@Resource
PasswordEncoder passwordEncoder;
@Test
public void contextLoads() {
System.out.println(passwordEncoder.encode("123456"));
//$2a$10$/Y5hXdm25sGMuPf2Xg3vvuqpZ131nPhW2LLG7nS/2gUKhHPvOM1V6
}
至此,我们将系统里面的所有的用户、角色、权限信息都通过UserDetailsService和UserDetails告知了Spring Security。但是多数朋友可能仍然不知道该怎样实现登录的功能,其实剩下的事情很简单了:
然后把这些信息通过配置方式告知Spring Security ,以上的配置信息名称都可以灵活修改。如果您不知道如何配置请参考本号之前的文章《formLogin登录认证模式》。
@Component
public interface MyUserDetailServiceMapper {
//根据userId查询用户基础信息
@Select("SELECT `username`,`password`,enabled\n" +
"FROM `sys_user` u \n" +
"WHERE u.`username`=#{userId}")
MyUserDetails findByUserName(@Param("userId")String userId);
//根据userId查询用户角色
@Select("SELECT roleCode\n" +
"FROM `sys_role` r \n" +
"LEFT JOIN `sys_user_role` ur ON ur.`roleId`=r.`id`\n" +
"LEFT JOIN `sys_user` u ON u.`id`=ur.`userId`\n" +
"WHERE u.`username`=#{userId}")
List<String> findRoleByUserName(@Param("userId")String userId);
//根据用户角色查询用户权限
@Select({
""})
List<String> findMenuByRole(@Param("roleCodes")List<String> roleCodes);
}
上图是资源鉴权规则完成之后的效果:
//判断本次访问资源是否有权限访问
@Component("rbacService")
public class MyRbacService {
public boolean hasPermission(HttpServletRequest request, Authentication authentication){
//获取到登录认证的主体信息
Object principal = authentication.getPrincipal();
if (principal instanceof UserDetails){
UserDetails userDetails = (UserDetails)principal;
//获取到当前请求访问的uri,比如这次访问的是"/syslog"
String uri = request.getRequestURI();
//根据uri构建授权访问资格,也是【本次要访问的资源】
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(uri);
//获取当前用户可以访问的所有资源
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
//判断用户可以访问的所有资源中是否包含本次访问的资源
//来判断这次请求是否有权限访问
return authorities.contains(simpleGrantedAuthority);
}
return false;
}
}
上述代码逻辑很简单:
如果使用admin用户登录,其加载数据内容如下图(根据之前章节调整RBAC模型数据库表里面的数据)。所以通过admin登录只能访问“用户管理”和“日志管理”功能。
如果使用admin用户登录,其加载数据内容如下图(根据之前章节调整RBAC模型数据库表里面的数据)。所以通过admin登录只能访问“具体业务一”和“具体业务二”功能。
从spring security 3.0
开始已经可以使用spring Expression
表达式来控制授权,允许在表达式中使用复杂的布尔逻辑来控制访问的权限。Spring Security可用表达式对象的基类是SecurityExpressionRoot。
部分朋友可能会对Authority和Role有些混淆。Authority作为资源访问权限可大可小,可以是某按钮的访问权限(如资源ID:biz1),也可以是某类用户角色的访问权限(如资源ID:ADMIN)。当Authority作为角色资源权限时,hasAuthority(‘ROLE_ADMIN’)与hasRole(‘ADMIN’)是一样的效果。
我们可以通过继承WebSecurityConfigurerAdapter,实现相关的配置方法,进行全局的安全配置(之前的章节已经讲过) 。下面就为大家介绍一些如何在全局配置中使用SPEL表达式。
config.antMatchers("/system/*").access("hasRole('admin') or hasAuthority('ROLE_admin')")
.anyRequest().authenticated();
这里我们定义了应用/person/*
URL的范围,只有拥有ADMIN
或者USER
权限的用户才能访问这些person资源。
这种方式,比较适合有复杂权限验证逻辑的情况,当Spring Security提供的默认表达式方法无法满足我们的需求的时候。实际上在上一节的动态加载资源鉴权规则里面,我么已经使用了这种方法。首先我们定义一个权限验证的RbacService。
@Component("rbacService")
@Slf4j
public class RbacService {
//返回true表示验证通过
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
//验证逻辑代码
return true;
}
public boolean checkUserId(Authentication authentication, int id) {
//验证逻辑代码
return true;
}
}
对于"/person/{id}"对应的资源的访问,调用rbacService的bean的方法checkUserId进行权限验证,传递参数为authentication对象和person的id。该id为PathVariable,以#开头表示。
config.antMatchers("/person/{id}").access("@rbacService.checkUserId(authentication,#id)")
.anyRequest().access("@rbacService.hasPermission(request,authentication)");
如果我们想实现方法级别的安全配置,Spring Security
提供了四种注解,分别是@PreAuthorize
, @PreFilter
, @PostAuthorize
和 @PostFilter
在Spring安全配置代码中,加上@EnableGlobalMethodSecurity注解
,开启方法级别安全配置功能
。
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@PreAuthorize 注解适合进入方法前的权限验证。只有拥有ADMIN角色才能访问findAll方法。
@PreAuthorize("hasRole('admin')")
public List<PersonDemo> findAll(){
return null;
}
如果当前登录用户没有PreAuthorize需要的权限,将抛出org.springframework.security.access.AccessDeniedException异常!
@PostAuthorize 在方法执行后再进行权限验证,适合根据返回值结果进行权限验证。Spring EL
提供返回对象能够在表达式语言中获取返回的对象returnObject
。下文代码只有返回值的name等于authentication对象的name(当前登录用户名)才能正确返回,否则抛出异常。
@PostAuthorize("returnObject.name == authentication.name")
public PersonDemo findOne(){
String authName =
SecurityContextHolder.getContext().getAuthentication().getName();
System.out.println(authName);
return new PersonDemo("admin");
}
PreFilter 针对参数进行过滤,下文代码表示针对ids参数进行过滤,只有id为偶数的元素才被作为参数传入函数。
//当有多个对象是使用filterTarget进行标注
@PreFilter(filterTarget="ids", value="filterObject%2==0")
public void delete(List<Integer> ids, List<String> usernames) {
}
PostFilter 针对返回结果进行过滤,特别适用于集合类返回值,过滤集合中不符合表达式的对象。
@PostFilter("filterObject.name == authentication.name")
public List<PersonDemo> findAllPD(){
List<PersonDemo> list = new ArrayList<>();
list.add(new PersonDemo("kobe"));
list.add(new PersonDemo("admin"));
return list;
}
如果使用admin登录系统,上面的函数返回值list中kobe将被过滤掉,只剩下admin。
下面代码可用于上面的测试
public class PersonDemo {
private String name;
public PersonDemo(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
@Service
public class MethodELService {
@PreAuthorize("hasRole('admin')")
public List<PersonDemo> findAll(){
return null;
}
@PostAuthorize("returnObject.name == authentication.name")
public PersonDemo findOne(){
String authName =
SecurityContextHolder.getContext().getAuthentication().getName();
System.out.println(authName);
return new PersonDemo("admin");
}
@PreFilter(filterTarget="ids", value="filterObject%2==0")
public void delete(List<Integer> ids, List<String> usernames) {
System.out.println();
}
@PostFilter("filterObject.name == authentication.name")
public List<PersonDemo> findAllPD(){
List<PersonDemo> list = new ArrayList<>();
list.add(new PersonDemo("kobe"));
list.add(new PersonDemo("admin"));
return list;
}
}
@Controller
public class BizpageController {
@Resource
MethodELService methodELDemo;
// 具体业务一
@GetMapping("/biz1")
public String updateOrder() {
//methodELDemo.findAll();
//methodELDemo.findOne();
/*List ids = new ArrayList<>();
ids.add(1);
ids.add(2);
methodELDemo.delete(ids,null);*/
List<PersonDemo> pds = methodELDemo.findAllPD();
return "biz1";
}
}
其实实现这个功能非常简单,只需要我们在重写WebSecurityConfigurerAdapter 方法配置HttpSecurity 的时候增加rememberMe()方法。(下面代码中省略了大量的关于Spring Security登录验证的配置,在本号此前的文章中已经讲过)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.rememberMe(); //实现记住我自动登录配置,核心的代码只有这一行
}
}
然后在登录表单中加入一个checkbox勾选框,name属性的值目前必须是“remember-me”(个性化更改的方法后面会讲)。
<label><input type="checkbox" name="remember-me" id="remember-me"/>记住密码
就是这么简单,我们就实现了记住我功能,默认效果是:2周内免登录。
很多朋友可能看了上面的实现过程心里都犯懵,这样就实现了?下面和大家说明一下这过程中间,都做了哪些事情。
RememberMeToken = username, expiryTime, signatureValue的Base64加密
signatureValue = username、expirationTime和passwod和一个预定义的key,并将他们经过MD5进行签名。
下图是TokenBasedRememberMeService中的源码
可能有的朋友会问:这样安全么?如果cookie被劫持,一定是不安全的,别人拿到了这个字符串在有效期内就可以访问你的应用。这就和你的钥匙token被盗了,你家肯定不安全是一个道理。 但是不存在密码被破解为明文的可能性,MD5 hash是不可逆的。
在实际的开发过程中,我们还可以根据需求做一些个性化的设置,如下:
.rememberMe()
.rememberMeParameter("remember-me-new") //对应前端传来数据的名称
.rememberMeCookieName("remember-me-cookie")//对应浏览器的cookie名称,推荐改一个不是很容易理解的名字,便于隐藏
.tokenValiditySeconds(2 * 24 * 60 * 60); //cookie的有效时间 【秒】
上面我们讲的方式,就是最简单的实现“记住我-自动登录”功能的方式。这种方式的缺点在于:token与用户的对应关系是在内存中存储的,当我们重启应用之后所有的token都将消失,即:所有的用户必须重新登陆。为此,Spring Security还给我们提供了一种将token存储到数据库中的方式,重启应用也不受影响。
有的文章说使用数据库存储方式是因为这种方式更安全,笔者不这么认为。虽然数据库存储的token的确不再是用户名、密码MD5加密字符串了,而是一个随机序列号。但是一旦你的随机序列号cookie被劫持,效果是一样的。好比你家有把密码锁:你把钥匙丢了和你把密码丢了,危害性是一样的。
上图是token数据库存储方式的实现原理和验证过程,下面我们就来实现一下。首先,我们需要键一张数据库表persistent_logins:
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
初始化一个PersistentTokenRepository类型的Spring bean,并将系统使用的DataSource注入到该bean中。(当然前提一定是你已经在Spring Boot的application.yml中配置好DataSource相关的连接属性,这里不再赘述)
@Autowired
private DataSource dataSource;
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
最后在Spring Security配置方法configure(HttpSecurity http)加上如下的个性化配置:
.rememberMe()
.tokenRepository(persistentTokenRepository())
使用记住密码登陆之后,数据库表中就会生成记录
其实使用Spring Security进行logout非常简单,只需要在spring Security配置类配置项上加上这样一行代码:http.logout()。关于spring Security配置类的其他很多实现、如:HttpBasic模式、formLogin模式、自定义登录验证结果、使用权限表达式、session会话管理,在本号的之前的文章已经都写过了。本节的核心内容就是在原有配置的基础上,加上这样一行代码:http.logout()。
@Configuration
@EnableWebSecurity
public class SecSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(final HttpSecurity http) throws Exception {
http.logout();
}
}
加上logout配置之后,在你的“退出”按钮上使用/logout作为请求登出的路径。
退出
logout功能我们就完成了。实际上的核心代码只有两行。
虽然我们简简单单的实现了logout功能,是不是还不足够放心?我们下面就来看一下Spring Security默认在logout过程中帮我们做了哪些动作。
通常对于一个应用来讲,以上动作就是logout功能所需要具备的功能了。
虽然Spring Security默认使用了/logout作为退出处理请求路径,登录页面作为退出之后的跳转页面。这符合绝大多数的应用的开发逻辑,但有的时候我们需要一些个性化设置,如下:
http.logout()
.logoutUrl("/signout")//之前前端指定登出路径
.logoutSuccessUrl("/aftersignout.html")//登出成功后跳转路径
.deleteCookies("JSESSIONID")//删除对应cookie
如果上面的个性化配置,仍然满足不了您的应用需求。可能您的应用需要在logout的时候,做一些特殊动作,比如登录时长计算,清理业务相关的数据等等。你可以通过实现LogoutSuccessHandler 接口来实现你的业务逻辑。
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication)
throws IOException, ServletException {
//这里书写你自己的退出业务逻辑
// 重定向到登录页
response.sendRedirect("/login.html");
}
}
然后进行配置使其生效,核心代码就是一行logoutSuccessHandler。注意logoutSuccessUrl不要与logoutSuccessHandler一起使用,否则logoutSuccessHandler将失效
。
@Configuration
@EnableWebSecurity
public class SecSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyLogoutSuccessHandler myLogoutSuccessHandler;
@Override
protected void configure(final HttpSecurity http) throws Exception {
http.logout()
.logoutUrl("/signout")
//.logoutSuccessUrl(``"/aftersignout.html"``)
.deleteCookies("JSESSIONID")
//自定义logoutSuccessHandler
.logoutSuccessHandler(myLogoutSuccessHandler);
}
}
要实现多次登录失败账户锁定的功能,我们需要先回顾一下基础知识:
我们之前《动态加载用户角色权限数据》定义过一个用于接收用户认证鉴权信息的实体MyUserDetails。当时我们没有使用到accountNonLocked字段,所以它的get方法也是直接返回true,表示该账户没有被锁定。
现在我们需要这个字段,Spring Security会根据该字段的值判断账户是否未被锁定,如果该字段的值为0(false),Spring Security会抛出LockedException,禁止用户登录。所以我们去sys_user表添加一个accountNonLocked字段,默认值是1(true),表示未被锁定。
需要注意的是mysql并没有boolean类型,int或tinyint类型,1就是true,0就是false。
同时修改MyUserDetails里面的isAccountNonLocked,让它返回值是从数据库加载的accountNonLocked字段数据。(这里如果不清楚为什么这么做,请回头看《动态加载用户角色权限数据》)
public class MyUserDetails implements UserDetails {
boolean accountNonLocked; //是否没被锁定
......//这里省略其他属性,省略其他get、set方法
@Override
public boolean isAccountNonLocked() {
//return true; //原来是这样的
return this.accountNonLocked; //现在改成这个样子
}
}
一般来说实现这个需求,我们需要针对每一个用户记录登录失败的次数nLock和锁定账户的到期时间releaseTime。具体你是把这2个信息存储在mysql、还是文件中、还是redis中等等,完全取决于你对你所处的应用架构适用性的判断。具体的实现逻辑无非就是:
这是一种非常典型的实现方式,笔者向大家介绍一款非常有用的开源软件叫做:ratelimitj。这个软件的功能主要是为API访问进行限流,也就是说可以通过制定规则限制API接口的访问频率。那恰好登录验证接口也是API的一种啊,我们正好也需要限制它在一定的时间内的访问次数。
首先需要将ratelimitj通过maven坐标引入到我们的应用里面来。我们使用的是内存存储的版本,还有redis存储的版本,大家可以根据自己的应用情况选用。
<dependency>
<groupId>es.moki.ratelimitjgroupId>
<artifactId>ratelimitj-inmemoryartifactId>
<version>0.7.0-RC1version>
dependency>
之后通过继承SimpleUrlAuthenticationFailureHandler ,实现onAuthenticationFailure()方法。该实现是针对登录失败的结果的处理,在我们之前的文章中已经讲过。
下文代码中含注释的部分是我们新加的代码,其他的都是《自定义登录成功及失败结果处理》中的实现。
@Component
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Value("${spring.security.logintype}")
private String loginType;
private static ObjectMapper objectMapper = new ObjectMapper();
//引入MyUserDetailsServiceMapper
@Resource
MyUserDetailsServiceMapper myUserDetailsServiceMapper;
//规则定义:1小时之内5次机会,第6次失败就触发限流行为(禁止访问)
Set<RequestLimitRule> rules =
Collections.singleton(RequestLimitRule.of(Duration.ofMinutes(60),5));
RequestRateLimiter limiter = new InMemorySlidingWindowRequestRateLimiter(rules);
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
//从request或request.getSession中获取登录用户名
String userId = request.getParameter("uname");
//默认提示信息
String errorMsg;
if(exception instanceof LockedException){
//账户被锁定了
errorMsg = "您已经多次登陆失败,账户已被锁定,请稍后再试!";
}else if(exception instanceof SessionAuthenticationException){
errorMsg = exception.getMessage();
}else{
errorMsg = "请检查您的用户名和密码输入是否正确";
}
//每次登陆失败计数器加1,并判断该用户是否已经到了触发了锁定规则
boolean reachLimit = limiter.overLimitWhenIncremented(userId);
if(reachLimit){
//如果触发了锁定规则,修改数据库 accountNonLocked字段锁定用户
myUserDetailsServiceMapper.updateLockedByUserId(userId);
errorMsg = "您多次登陆失败,账户已被锁定,请稍后再试!";
}
if ("JSON".equalsIgnoreCase(loginType)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
AjaxResponse.userInputError(errorMsg)
));
} else {
response.setContentType("text/html;charset=UTF-8");
super.onAuthenticationFailure(request, response, exception);
}
}
}
MyUserDetailsServiceMapper
新增updateLockedByUserId(user);
是用来更新数据库accountNonLocked字段的(更新为锁定状态),代码如下:@Update({
"UPDATE sys_user u \n" +
" SET u.accountNonLocked = 0 \n" +
" WHERE u.username = #{userId}" })
int updateLockedByUserId(@Param("userId") String userId);
该字段被更新为0之后,用户下次登录会从sys_user加载UserDetails数据。所以MyUserDetailsServiceMapper
查询SQL增加字段
当Spring Security发现accountNonLocked=0的时候,就会抛出LockedException(即使输入正确的用户名密码也不行,因为这个账户已经被锁定了)。从而登陆失败再次进入AuthenticationFailureHandler ,我们将LockedException转换为提示信息:“您已经多次登陆失败,账户已被锁定,请稍后再试!”。
需要注意的是,我们这种实现方式,实际上是有两个锁定状态
所以账户解锁的2个条件缺一不可:一是到达时间窗口限制边界(或重启应用),二是accountNonLocked字段为1 。但是更重要的是如何选择重置锁定状态的时机。笔者能想到几种方案如下
根据你系统的不同的情况,选择性的实现即可。
验证码实际上和谜语有点像,分为谜面和谜底。谜面通常是图片,谜底通常为文字。谜面用于展现,谜底用于校验。
总之,不管什么形式的谜面,最后用户的输入内容要和谜底进行验证。
图中蓝色为服务端、澄粉色为客户端。
这是一种最典型的验证码实现方式,实现方式也比较简单。
这种实现方式的优点就是比较简单,缺点就是:因为一套应用部署一个session,当我们把应用部署多套如:A、B、C,他们各自有一个session并且不共享。导致的结果就是验证码和图片由A生成,但是验证请求发送到了B,这样就不可能验证通过。
在第二小节讲到的问题,实际上不是验证码的问题,而是如何保证session唯一性或共享性的问题。主要的解决方案有两种:
可能出于主机资源的考虑,可能出于系统架构的考量,有些应用是无状态的。
那么对于这些无状态的应用,我们就无法使用session,或者换个说法从团队开发规范上就不让使用session。那么我们的验证码该怎么做?
这种做法的缺陷是显而易见的:实际上就是将验证码文字在客户端服务端之间走了一遍。虽然是加密后的验证码文字,但是有加密就必须有解密,否则无法验证。所以更为稳妥的做法是为每一个用户生成密钥,并将密钥保存到数据库里面,在对应的阶段内调用密钥进行加密或者解密。
从密码学的角度讲,没有一种对称的加密算法是绝对安全的。所以更重要的是保护好你的密钥。正如没有一把锁头是绝对安全的,更重要的是保护好你的钥匙。
通过maven坐标引入kaptcha
<dependency>
<groupId>com.github.pengglegroupId>
<artifactId>kaptchaartifactId>
<version>2.3.2version>
<exclusions>
<exclusion>
<artifactId>javax.servlet-apiartifactId>
<groupId>javax.servletgroupId>
exclusion>
exclusions>
dependency>
kaptcha的配置不符合yaml的规范格式
,所以只能采用properties
。需配合注解PropertySourc使用。kaptcha.border=no
kaptcha.border.color=105,179,90
kaptcha.image.width=100
kaptcha.image.height=45
kaptcha.textproducer.font.color=blue
kaptcha.textproducer.font.size=35
kaptcha.textproducer.char.length=4
kaptcha.textproducer.font.names=宋体,楷体,微软雅黑
下面的代码加载了配置文件中的kaptcha配置(参考Spring Boot的配置加载),如果是独立的properties文件,需加上PropertySource注解说明。
另外,我们通过加载完成的配置,初始化captchaProducer的Spring Bean,用于生成验证码。
@PropertySource(value = {
"classpath:kaptcha.properties"})//加载外部配置文件
public class CaptchaConfig {
@Value("${kaptcha.border}")
private String border;
@Value("${kaptcha.border.color}")
private String borderColor;
@Value("${kaptcha.textproducer.font.color}")
private String fontColor;
@Value("${kaptcha.image.width}")
private String imageWidth;
@Value("${kaptcha.image.height}")
private String imageHeight;
@Value("${kaptcha.textproducer.char.length}")
private String charLength;
@Value("${kaptcha.textproducer.font.names}")
private String fontNames;
@Value("${kaptcha.textproducer.font.size}")
private String fontSize;
@Bean(name = "kaptchaProducer")
public DefaultKaptcha getKaptchaBean(){
DefaultKaptcha kaptcha = new DefaultKaptcha();
Properties properties = new Properties();
properties.setProperty("kaptcha.border",border);
properties.setProperty("kaptcha.border.color", borderColor);
properties.setProperty("kaptcha.textproducer.font.color", fontColor);
properties.setProperty("kaptcha.image.width", imageWidth);
properties.setProperty("kaptcha.image.height", imageHeight);
properties.setProperty("kaptcha.textproducer.char.length", charLength);
properties.setProperty("kaptcha.textproducer.font.names", fontNames);
properties.setProperty("kaptcha.textproducer.font.size",fontSize);
kaptcha.setConfig(new Config(properties));
return kaptcha;
}
}
至此,Kaptcha开源验证码软件的配置我们就完成了,如果发现IDEA环境下配置文件读取中文乱码,修改如下配置。
生成验证码的Controller。同时需要开放路径"/kaptcha"的访问权限,配置成不需登录也无需任何权限即可访问的路径。如何进行配置,笔者之前的文章已经讲过了。
@RestController
public class kaptchaController {
@Autowired
private DefaultKaptcha kaptchaProducer;
@GetMapping("/getKaptcha")
public void kaptcha(HttpSession session, HttpServletResponse response) throws IOException {
//生成谜底
String text = kaptchaProducer.createText();
session.setAttribute("kaptcha_key",new CaptchaVo(text,60*2));
//设置请求头
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
response.addHeader("Cache-Control", "post-check=0, pre-check=0");
response.setHeader("Pragma", "no-cache");
response.setContentType("image/jpeg");
//根据谜底生成对应的图片
BufferedImage image = kaptchaProducer.createImage(text);
//ServletOutputStream会自动关流
try(ServletOutputStream out = response.getOutputStream();)
{
//参数1:图片 参数2:格式 参数3:流
ImageIO.write(image,"jpg",out);
out.flush();
}
}
}
我们要把CaptchaImageVO保存到session里面。所以该类中不要加图片,只保存验证码文字和失效时间,用于后续验证即可。把验证码图片保存起来既没有用处,又浪费内存。
@Data
public class CaptchaVo {
//谜底
private String code;
//过期时间
private LocalDateTime expireTime;
public CaptchaVo(String code,int expireAfterSeconds){
this.code=code;
this.expireTime=LocalDateTime.now().plusSeconds(expireAfterSeconds);
}
public boolean isExpired(){
return LocalDateTime.now().isAfter(expireTime);
}
}
把如下代码加入到登录页面合适的位置,注意图片img标签放到登录表单中。
<span>验证码span><input type="text" name="captchaCode" id="captchaCode" />
<img src="/getKaptcha" id="kaptcha" width="110px" height="40px"/> <br>
<script>
window.onload=function(){
var kaptchaImg = document.getElementById("kaptcha");
kaptchaImg.onclick = function(){
kaptchaImg.src = "/getKaptcha?" + Math.floor(Math.random() * 100)
}
}
</script>
需要为“/getKaptcha”配置permitAll公开访问权限,否则无法访问到
@Component
public class CaptchaCodeFilter extends OncePerRequestFilter {
@Autowired
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
//判断uri是不是/login ,请求方式为post
if ("/login".equals(httpServletRequest.getRequestURI()) && "post".equalsIgnoreCase(httpServletRequest.getMethod())){
try {
//验证谜底与用户输入是否匹配
validate(new ServletWebRequest(httpServletRequest));
} catch (AuthenticationException e) {
myAuthenticationFailureHandler.onAuthenticationFailure(httpServletRequest,httpServletResponse,e);
return;
}
}
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
//验证码校验
private void validate(ServletWebRequest servletWebRequest) throws ServletRequestBindingException {
HttpSession session = servletWebRequest.getRequest().getSession();
//从session中取出用户输入的验证码
String codeInRequest = ServletRequestUtils.getStringParameter(
servletWebRequest.getRequest(),"captchaCode");
if (StringUtils.isEmpty(codeInRequest)){
throw new SessionAuthenticationException("验证码不能为空");
}
// 获取session池中的验证码谜底
CaptchaVo codeInSession = (CaptchaVo) session.getAttribute("kaptcha_key");
if(Objects.isNull(codeInSession)) {
throw new SessionAuthenticationException("您输入的验证码不存在");
}
// 校验服务器session池中的验证码是否过期
if(codeInSession.isExpired()) {
session.removeAttribute("kaptcha_key");
throw new SessionAuthenticationException("验证码已经过期");
}
// 请求验证码校验
if(!codeInSession.getCode().equals(codeInRequest)) {
throw new SessionAuthenticationException("验证码不匹配");
}
}
}
在MyAuthenticationFailureHandler将异常的message转换为:用户响应的message。即上文异常中定义的:
最后将CaptchaCodeFilter过滤器放到用户名密码登录过滤器之前执行。login.html登录请求中要传递参数:captchaCode