1、RBAC权限管理模型
一、RBAC权限模型简介
RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。模型中有几个关键的术语:
- 用户:系统接口及功能访问的操作者
- 权限:能够访问某接口或者做某操作的授权资格
- 角色:具有一类相同操作权限的用户的总称
RBAC权限模型核心授权逻辑如下:
- 某用户是什么角色?
- 某角色具有什么权限?
- 通过角色的权限推导用户的权限
二、RBAC的演化进程
2.1.用户与权限直接关联
想到权限控制,人们最先想到的一定是用户与权限直接关联的模式,简单地说就是:某个用户具有某些权限。如图:
- 张三具有创建用户和删除用户的权限,所以他可能系统维护人员
- 李四具有产品记录管理和销售记录管理权限,所以他可能是一个业务销售人员
这种模型能够清晰的表达用户与权限之间的关系,足够简单。但同时也存在问题:
- 现在用户是张三、李四,以后随着人员增加,每一个用户都需要重新授权
- 或者张三、李四离职,需要针对每一个用户进行多种权限的回收
2.2.一个用户拥有一个角色
在实际的团体业务中,都可以将用户分类。比如对于薪水管理系统,通常按照级别分类:经理、高级工程师、中级工程师、初级工程师。也就是按照一定的角色分类,通常具有同一角色的用户具有相同的权限。这样改变之后,就可以将针对用户赋权转换为针对角色赋权。因为角色少、权限多,所以基于角色管理权限,减少用户在授权与权限回收过程中的过多操作。
- 一个用户有一个角色
- 一个角色有多个操作(菜单)权限
- 一个操作权限可以赋予多个角色
我们可以用下图中的数据库设计模型,描述这样的关系。
维护一对多的关系的话:一般相关的外键,建立在多的一方
下面的案例是:用户有很多,但是角色就这么几个,所以将外键关联方在用户表中【也就1对多,多的一方】
2.3 一个用户一个或多个角色
但是在实际的应用系统中,一个用户一个角色远远满足不了需求。如果我们希望一个用户既担任销售角色、又暂时担任副总角色。该怎么做呢?为了增加系统设计的适用性,我们通常设计:
- 一个用户有一个或多个角色
- 一个角色包含多个用户
- 一个角色有多种权限
- 一个权限可以赋予多个角色
我们可以用下图中的数据库设计模型,描述这样的关系。
- sys_user是用户信息表,用于存储用户的基本信息,如:用户名、密码
- sys_role是角色信息表,用于存储系统内所有的角色
- sys_menu是系统的菜单信息表,用于存储系统内所有的菜单。用id与父id的字段关系维护一个菜单树形结构。
- sys_user_role是用户角色多对多关系表,一条userid与roleid的关系记录表示该用户具有该角色,该角色包含该用户。
- sys_role_menu是角色菜单(权限)关系表,一条roleid与menuid的关系记录表示该角色由某菜单权限,该菜单权限可以被某角色访问。
三、页面访问权限与操作权限
- 页面访问权限: 所有系统都是由一个个的页面组成,页面再组成模块,用户是否能看到这个页面的菜单、是否能进入这个页面就称为页面访问权限。
- 操作权限: 用户在操作系统中的任何动作、交互都需要有操作权限,如增删改查等。比如:某个按钮,某个超链接用户是否可以点击,是否应该看见的权限。
四、数据权限
数据权限比较好理解,就是某个用户能够访问和操作哪些数据。
- 通常来说,数据权限由用户所属的组织来确定。比如:生产一部只能看自己部门的生产数据,生产二部只能看自己部门的生产数据;销售部门只能看销售数据,不能看财务部门的数据。而公司的总经理可以看所有的数据。
- 在实际的业务系统中,数据权限往往更加复杂。非常有可能销售部门可以看生产部门的数据,以确定销售策略、安排计划等。
所以为了面对复杂的需求,数据权限的控制通常是由程序员书写个性化的SQL来限制数据范围的,而不是交给权限模型或者Spring Security或shiro来控制。当然也可以从权限模型或者权限框架的角度去解决这个问题,但适用性有限。
2、结合真实系统讲解RBAC实现
一、回顾RBAC权限模型
- 用户与角色之间是多对多的关系,一个用户有多个角色,一个角色包含多个用户
- 角色与权限之间是多对多关系,一个角色有多种权限,一个权限可以属于多个角色
上图中:
- User是用户表,存储用户基本信息
- Role是角色表,存储角色相关信息
- Menu(菜单)是权限表,存储系统包含哪些菜单及其属性
- UserRole是用户和角色的关系表
- RoleMenu是角色和权限的关系表
本文讲解只将权限控制到菜单的访问级别,即控制页面的访问权限。如果想控制到页面中按钮级别的访问,可以参考Menu与RoleMenu的模式同样的实现方式。或者干脆在menu表里面加上一个字段区别该条记录是菜单项还是按钮。
二、参考即有成型系统
为了能够给大家把功能的需求讲明白,我们参考我自己开发的一个开源项目:dongbb。
- 该开源项目的底层实现就是采用前三章的知识完成的,所以和本书的知识体系是一致的。
- 该开源项目具备权限管理功能的界面,所以能够更方便大家去理解RBAC权限管理模型。
演示地址:http://123.56.169.21/dongbb/
演示环境用户密码:admin/Abcd1234
大家可以一边看系统,一边通过下文来理解RBAC权限管理模型的实现。
请大家爱惜演示环境,自己创建的数据自己删除、修改。不要去删除修改他人创建的数据。如果多次删改“权限数据”造成演示环境,无法使用的情况,我将采取禁用权限的手段,大家的可操作空间将会缩小。
三、组织部门管理
3.1.需求分析
之所以先将组织部门管理提出来讲一下,是因为组织管理没有在我们上面的RBAC权限模型中进行体现。但是“组织”这样一个实体仍然是,后端管理系统的一个重要组成部分。通常有如下的需求:
- “组织”要能体现出上下级的结构(如上图中的红框)。在关系型数据库中。这就需要使用到“组织”id及上级“组织”id,来组合成一个树形结构。这个知识是SQL学习中必备的知识,如果您还不知道,请自行补课。
- 如果组织与用户之间是一对多的关系,就在用户表中加上一个org_id标识用户所属的组织。原则是:实体关系在多的那一边维护。比如:是让老师记住自己的学生容易,还是让学生记住自己的老师更容易?
- 如果组织与用户是多对多关系,这种情况现实需求也有可能存在。比如:某人在某单位既是生产部长,又是技术部长。所以他及归属于技术部。也归属于生产部。对于这种情况有两种解决方案,把该人员放到公司级别,而不是放到部门级别。另外一种就是从数据库结构上创建User与Org组织之间的多对多关系表。
- 组织信息包含一些基本信息,如组织名称、组织状态、展现排序、创建时间
- 另外,要有基本的组织的增删改查功能
3.2 组织部门表的CreateSQL
以下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)。
四、菜单管理
4.1 需求分析
- 由上图可以看出,菜单仍然是树形结构,所以数据库表必须有id与menu_pid字段。菜单的树形结构一定程度上决定了系统用户左侧菜单的展示结构。
- 必要字段:菜单跳转的url、是否启用、菜单排序、菜单的icon矢量图标等
- 最重要的是菜单要有一个权限标志,具有唯一性。通常可以使用菜单跳转的url路径作为权限标志。此标志作为权限管理框架识别用户是否具有某个页面查看权限的重要标志
- 需要具备菜单的增删改查基本功能
- 如果希望将菜单权限和按钮超链接相关权限放到同一个表里面,可以新增一个字段。用户标志该权限记录是菜单访问权限还是按钮访问权限。
4.2 菜单表的CreateSQL
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;
五、角色管理
上图为角色修改及分配权限的页面
5.1.需求分析
- 角色本身的管理需要注意的点非常少,就是简单的角色分类的增删改查。重点在于如何为该角色分配权限,比如:该角色可以访问哪些菜单。
- 角色表包含角色id,角色名称,备注、排序顺序这些基本信息就足够了
- 为角色分配权限:以角色为基础勾选菜单权限或者操作权限。保存的时候先删除sys_role_menu表内该角色的所有记录,在将新勾选的权限数据逐条插入sys_role_menu表。
- sys_role_menu的结构很简单,记录role_id与menu_id,一个角色拥有某一个权限(如:菜单访问权限)就是一条记录。
- 角色要有一个全局唯一的标识,因为角色本身也是一种权限。可以通过判断登陆用户的角色来判断某用户的操作是否合法,会用到角色的全局唯一标识。
- 通常的需求:不会在角色管理界面为角色添加用户,而是在用户管理界面为用户分配角色。
5.2.角色表与角色菜单权限关联表的的CreateSQL
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;
六、用户管理
6.1.需求分析
- 上图中点击左侧的组织菜单树结点,要能显示出该组织下的所有系统用户。在组织与用户是一对多的关系中,需要在用户表加上org_id字段,用于查询某个组织下的所有用户。
- 用户表中要保存用户的用户名、加密后的密码。页面提供密码修改或重置的功能。
- 实现用户基本信息的增删改查功能
- 为用户分配角色:以用户为基础为用户分配角色,保存角色的时候先删除sys_user_role表内该用户的所有角色记录,在将新勾选的角色数据逐条插入sys_user_role表。
- sys_user_role的结构很简单,记录role_id与user_id,一个用户拥有某一个角色就是一条记录。
6.2.sys_user 用户信息表及用户角色关系表的CreateSQL
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;
在用户的信息表中,体现了一些隐藏的需求。如:多次登录锁定与锁定到期时间的关系。账号有效期的设定规则等。
当然用户表中,根据业务的不同还可能加更多的信息,比如:用户头像等等。但是通常在比较大型的业务系统开发中,业务模块中使用的用户表和在权限管理模块使用的用户表通常不是一个,而是根据某些唯一字段弱关联,分开存放。这样做的好处在于:经常发生变化的业务需求,不会去影响不经常变化的权限模型。
3、动态加载用户角色权限数据
一、动态加载用户角色权限数据
在本号之前的文章中,已经介绍了Spring Security的formLogin登录认证模式,RBAC的权限控制管理模型,并且针对Spring Security的登录认证逻辑源码进行了解析等等。
我们所有的用户、角色、权限信息都是在配置文件里面写死的,然而在实际的业务系统中,这些信息通常是存放在RBAC权限模型的数据库表中的。我们本节的内容就是,把这些信息从数据库里面进行加载。
下面我们来回顾一下其中的核心概念:
- RBAC的权限模型可以从用户获取为用户分配的一个或多个角色,从用户的角色又可以获取该角色的多种权限。通过关联查询可以获取某个用户的角色信息和权限信息。
- 在源码解析的文章中,我们知道如果我们不希望用户、角色、权限信息写死在配置里面。我们应该实现UserDetails与UserDetailsService接口,从而从数据库或者其他的存储上动态的加载这些信息。
以上是对一些核心的基础知识的总结,如果您对这些知识还不是很清晰,建议您先往下读本文。如果看完本文仍然理解困难,建议您翻看之前的文章。
二、UserDetails与UserDetailsService接口
在UserDetails中设置我们的用户相关数据
在UserDetailsService中获取提供给springsecurity数据
- UserDetailsService接口有一个方法叫做loadUserByUsername,我们实现动态加载用户、角色、权限信息就是通过实现该方法。函数见名知义:通过用户名加载用户。该方法的返回值就是UserDetails。
- UserDetails就是用户信息,即:用户名、密码、该用户所具有的权限。
下面我们来看一下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;
}
三、实现UserDetails 接口
我们提供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方法。
- get方法由Spring Security调用,获取认证及鉴权的数据
- 我们通过set方法或构造函数为 Spring Security提供UserDetails数据(从数据库查询)。
- 当enabled的值为false的时候,Spring Security会自动的禁用该用户,禁止该用户进行系统登录。
- 通常数据库表sys_user字段要和MyUserDetails 属性一一对应,比如username、password、enabled。
目前数据库表里面
没有定义accountNonExpired、accountNonLocked、credentialsNonExpired这三个字段
,我一般不喜欢搞这么多字段控制用户的登录认证行为,笔者觉得简单点好,一个enabled字段就够了
。所以这三个成员变量对应的get方法,直接返回true即可。(后续章节实现《多次登陆失败账户锁定功能》的时候,我们用到了accountNonLocked,到时候我们再到数据库里面添加字段)
四、实现UserDetailsService接口
在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;
}
}
- 角色是一种特殊的权限,在Spring Security我们可以使用hasRole(角色标识)表达式判断用户是否具有某个角色,决定他是否可以做某个操作;通过hasAuthority(权限标识)表达式判断是否具有某个操作权限。
- 上述实现中用到的MyUserDetailsServiceMapper 是Mybatis操作数据库的接口实现,看文末代码。
五、注册UserDetailsService
重写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。但是多数朋友可能仍然不知道该怎样实现登录的功能,其实剩下的事情很简单了:
- 写一个登录界面,写一个登录表单,表单使用post方法提交到默认的/login路径
- 表单的用户名、密码字段名称默认是username、password。
- 写一个登录成功之后的跳转页面,比如index.html
然后把这些信息通过配置方式告知Spring Security ,以上的配置信息名称都可以灵活修改。如果您不知道如何配置请参考本号之前的文章《formLogin登录认证模式》。
附录:Mybatis持久层数据接口
- 需要在系统内集成持久层框架,用于访问数据库。我是用Mybatis实现的,你可以用JDBC、JPA,看你自己觉得怎样方便。
- 实现三个接口:一是通过userId(用户名)查询用户信息;二是根据用户名查询用户角色列表;三是通过角色列表查询权限列表。
@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({"<script>",
"SELECT url " ,
"FROM sys_menu m " ,
"LEFT JOIN `sys_role_menu` rm ON rm.`menuId`=m.`id` " ,
"LEFT JOIN sys_role r ON r.id = rm.roleId ",
"WHERE r.roleCode IN ",
"<foreach collection='roleCodes' item='roleCode' open='(' separator=',' close=')'>",
"#{roleCode}",
"</foreach>",
"</script>"})
List<String> findMenuByRole(@Param("roleCodes")List<String> roleCodes);
}
4、动态加载资源鉴权规则
一、实现效果
上图是资源鉴权规则完成之后的效果:
- 首先将静态规则去掉(注释掉的部分内容),这部分内容我们将替换为动态从数据库加载
- 登录页面“login.html”和登录认证处理路径“/login”需完全对外开发,不需任何鉴权就可以访问
- 首页"/index"必须authenticated,即:登陆之后才能访问。不做其他额外鉴权规则控制。
- 最后,其他的资源的访问我们通过权限规则表达式实现,表达式规则中使用了rbacService,这个类我们自定义实现。该类服务hasPermission从内存(或数据库)动态加载资源匹配规则,进行资源访问鉴权。
二、动态资源鉴权规则
//判断本次访问资源是否有权限访问
@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;
}
}
上述代码逻辑很简单:
- 首先从authentication中获取principal (即UserDetails),UserDetails里面包含authorities(即当前登录用户可以访问的所有的资源访问路径、资源唯一标识)。
- 如果authorities列表中任何一个元素,能够和request.getRequestURI()请求资源路径相匹配,则表示该用户具有访问该资源的权限。
- hasPermission有两个参数,第一个参数是HttpServletRequest ,第二个参数是Authentication认证主体
- 用户每一次访问系统资源的时候,都会执行这个方法,判断该用户是否具有访问该资源的权限。
三、测试一下
如果使用admin用户登录,其加载数据内容如下图(根据之前章节调整RBAC模型数据库表里面的数据)。所以通过admin登录只能访问“用户管理”和“日志管理”功能。
如果使用admin用户登录,其加载数据内容如下图(根据之前章节调整RBAC模型数据库表里面的数据)。所以通过admin登录只能访问“具体业务一”和“具体业务二”功能。
5、权限表达式使用方法总结
一、SPEL表达式权限控制
从spring security 3.0
开始已经可以使用spring Expression
表达式来控制授权,允许在表达式中使用复杂的布尔逻辑来控制访问的权限。Spring Security可用表达式对象的基类是SecurityExpressionRoot。
部分朋友可能会对Authority和Role有些混淆。Authority作为资源访问权限可大可小,可以是某按钮的访问权限(如资源ID:biz1),也可以是某类用户角色的访问权限(如资源ID:ADMIN)。当Authority作为角色资源权限时,hasAuthority(‘ROLE_ADMIN’)与hasRole(‘ADMIN’)是一样的效果。
二、SPEL在全局配置中的使用
我们可以通过继承WebSecurityConfigurerAdapter,实现相关的配置方法,进行全局的安全配置(之前的章节已经讲过) 。下面就为大家介绍一些如何在全局配置中使用SPEL表达式。
2.1.URL安全表达式
config.antMatchers("/system/*").access("hasRole('admin') or hasAuthority('ROLE_admin')")
.anyRequest().authenticated();
这里我们定义了应用/person/*
URL的范围,只有拥有ADMIN
或者USER
权限的用户才能访问这些person资源。
2.2.安全表达式中引用bean
这种方式,比较适合有复杂权限验证逻辑的情况,当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)");
三、 Method表达式安全控制
如果我们想实现方法级别的安全配置,Spring Security
提供了四种注解,分别是@PreAuthorize
, @PreFilter
, @PostAuthorize
和 @PostFilter
3.1.开启方法级别注解的配置
在Spring安全配置代码中,加上@EnableGlobalMethodSecurity注解
,开启方法级别安全配置功能
。
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
3.2 使用PreAuthorize注解
@PreAuthorize 注解适合进入方法前的权限验证。只有拥有ADMIN角色才能访问findAll方法。
@PreAuthorize("hasRole('admin')")
public List<PersonDemo> findAll(){
return null;
}
如果当前登录用户没有PreAuthorize需要的权限,将抛出org.springframework.security.access.AccessDeniedException异常!
3.3 使用PostAuthorize注解
@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");
}
3.4 使用PreFilter注解
PreFilter 针对参数进行过滤,下文代码表示针对ids参数进行过滤,只有id为偶数的元素才被作为参数传入函数。
//当有多个对象是使用filterTarget进行标注
@PreFilter(filterTarget="ids", value="filterObject%2==0")
public void delete(List<Integer> ids, List<String> usernames) {
}
3.5 使用PostFilter 注解
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<Integer> ids = new ArrayList<>();
ids.add(1);
ids.add(2);
methodELDemo.delete(ids,null);*/
List<PersonDemo> pds = methodELDemo.findAllPD();
return "biz1";
}
}
6、RememberMe记住我功能
一、最简实践
其实实现这个功能非常简单,只需要我们在重写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"/>记住密码</label
就是这么简单,我们就实现了记住我功能,默认效果是:2周内免登录。
二、实现原理
很多朋友可能看了上面的实现过程心里都犯懵,这样就实现了?下面和大家说明一下这过程中间,都做了哪些事情。
- 当我们登陆的时候,除了用户名、密码,我们还可以勾选remember-me。
- 如果我们勾选了remember-me,当我们登录成功之后服务端会生成一个Cookie返回给浏览器,这个Cookie的名字默认是remember-me;值是一个token令牌。
- 当我们在有效期内再次访问应用时,经过RememberMeAuthenticationFilter,读取Cookie中的token进行验证。验正通过不需要再次登录就可以进行应用访问。
2.1.RememberMeToken 的组成
RememberMeToken = username, expiryTime, signatureValue的Base64加密
signatureValue = username、expirationTime和passwod和一个预定义的key,并将他们经过MD5进行签名。
下图是TokenBasedRememberMeService中的源码
可能有的朋友会问:这样安全么?如果cookie被劫持,一定是不安全的,别人拿到了这个字符串在有效期内就可以访问你的应用。这就和你的钥匙token被盗了,你家肯定不安全是一个道理。 但是不存在密码被破解为明文的可能性,MD5 hash是不可逆的。
2.2.过滤器执行流程
- 第一次登录请求的时候,用户使用其他验证方式进行登录(如用户名密码),勾选remember-me,并生成RememberMeToken 令牌。
- 第二次登陆的时候使用RememberMeToken令牌(就不用输入用户名密码了),RememberMeAuthenticationFilter在Spring Security过滤器链中处于整体偏后的位置,所以只有当各种传统的登录方式都无法完成验证的情况下,才走RememberMeAuthenticationFilter,这也是符合实际需求的。
三、个性化配置
在实际的开发过程中,我们还可以根据需求做一些个性化的设置,如下:
.rememberMe()
.rememberMeParameter("remember-me-new") //对应前端传来数据的名称
.rememberMeCookieName("remember-me-cookie")//对应浏览器的cookie名称,推荐改一个不是很容易理解的名字,便于隐藏
.tokenValiditySeconds(2 * 24 * 60 * 60); //cookie的有效时间 【秒】
- tokenValiditySeconds用于设置token的有效期,即多长时间内可以免除重复登录,单位是秒。不修改配置情况下默认是2周。
- 通过rememberMeParameter设置from表单“自动登录”勾选框的参数名称。如果这里改了,from表单中checkbox的name属性要对应的更改。如果不设置默认是remember-me。
- rememberMeCookieName设置了保存在浏览器端的cookie的名称,如果不设置默认也是remember-me。如下图中查看浏览器的cookie。
四、token数据库存储方式
上面我们讲的方式,就是最简单的实现“记住我-自动登录”功能的方式。这种方式的缺点在于: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())
- 测试
使用记住密码登陆之后,数据库表中就会生成记录
7、退出登录功能的实现
一、logout最简及最佳实践
其实使用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作为请求登出的路径。
<a href="/logout" >退出</a>
logout功能我们就完成了。实际上的核心代码只有两行。
二、默认的logout做了什么?
虽然我们简简单单的实现了logout功能,是不是还不足够放心?我们下面就来看一下Spring Security默认在logout过程中帮我们做了哪些动作。
- 当前session失效,即:logout的核心需求,session失效就是访问权限的回收。
- 删除当前用户的 remember-me“记住我”功能信息
- clear清除当前的 SecurityContext
- 重定向到登录页面,loginPage配置项指定的页面
通常对于一个应用来讲,以上动作就是logout功能所需要具备的功能了。
三、个性化配置
虽然Spring Security默认使用了/logout作为退出处理请求路径,登录页面作为退出之后的跳转页面。这符合绝大多数的应用的开发逻辑,但有的时候我们需要一些个性化设置,如下:
http.logout()
.logoutUrl("/signout")//之前前端指定登出路径
.logoutSuccessUrl("/aftersignout.html")//登出成功后跳转路径
.deleteCookies("JSESSIONID")//删除对应cookie
- 通过指定logoutUrl配置改变退出请求的默认路径,当然html退出按钮的请求url也要修改
- 通过指定logoutSuccessUrl配置,来显式指定退出之后的跳转页面
- 还可以使用deleteCookies删除指定的cookie,参数为cookie的名称
四、LogoutSuccessHandler
如果上面的个性化配置,仍然满足不了您的应用需求。可能您的应用需要在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);
}
}
8、多次登录失败账户锁定
一、基础知识回顾
要实现多次登录失败账户锁定的功能,我们需要先回顾一下基础知识:
- Spring Security 不需要我们自己实现登录验证逻辑,而是将用户、角色、权限信息以实现UserDetails和UserDetailsService接口的方式告知Spring Security。具体的登录验证逻辑Spring Security 会帮助我们实现。
- 《动态加载用户角色权限数据》定义了UserDetails接口实现中有一个方法叫做isAccountNonLocked()用于判断账号是否被锁定,也就是说我们应该通过该方法对应的set方法setAccountNonLocked(false)告知Spring Security该登录账户被锁定。
- 那么应该在哪里判断账号登录失败的次数并执行锁定机制呢?当然是我们之前文章给大家介绍的《自定义登录成功及失败结果处理》的AuthenticationFailureHandler。
二、MyUserDetails修改
我们之前《动态加载用户角色权限数据》定义过一个用于接收用户认证鉴权信息的实体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中等等,完全取决于你对你所处的应用架构适用性的判断。具体的实现逻辑无非就是:
- 登陆失败之后,从存储中将nLock取出来加1。
- 如果nLock大于登陆失败阈值(比如3次),则将nLock=0,然后设置releaseTime为当前时间加上锁定周期。通过setAccountNonLocked(false)告知Spring Security该登录账户被锁定。
- 如果nLock小于等于1,则将nLock再次存起来。
- 在一个合适的时机,将锁定状态重置为setAccountNonLocked(true)。
这是一种非常典型的实现方式,笔者向大家介绍一款非常有用的开源软件叫做:ratelimitj。这个软件的功能主要是为API访问进行限流,也就是说可以通过制定规则限制API接口的访问频率。那恰好登录验证接口也是API的一种啊,我们正好也需要限制它在一定的时间内的访问次数。
四、具体实现
首先需要将ratelimitj通过maven坐标引入到我们的应用里面来。我们使用的是内存存储的版本,还有redis存储的版本,大家可以根据自己的应用情况选用。
<dependency>
<groupId>es.moki.ratelimitj</groupId>
<artifactId>ratelimitj-inmemory</artifactId>
<version>0.7.0-RC1</version>
</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);
}
}
}
- 核心实现注意看代码中的注释,userId就是登陆用户名,你可以在request中取出来。
- 代码中的MyUserDetails为UserDetails的实现类,如果不知道如何实现请参考《动态加载用户角色权限数据》
- 在
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转换为提示信息:“您已经多次登陆失败,账户已被锁定,请稍后再试!”。
五、重置数据库锁定状态的时机
需要注意的是,我们这种实现方式,实际上是有两个锁定状态
- 第一个锁定状态是由ratelimitj-inmemory控制的窗口,是存储在内存中的,锁定时间不到,这个窗口是打不开的。除非你重启应用,内存被刷新。
- 第二个锁定状态是数据库sys_user表的accountNonLocked字段,你可以人为去修改。但是第一个锁定状态不打开,你人为修改accountNonLocked字段是没用的,登录还是会被再次更新为账户锁定。
所以账户解锁的2个条件缺一不可:一是到达时间窗口限制边界(或重启应用),二是accountNonLocked字段为1 。但是更重要的是如何选择重置锁定状态的时机。笔者能想到几种方案如下
- 一般的小型的管理系统,在“用户管理”功能模块提供一个功能,重置账户可用及锁定状态。
- 下一次登陆的时候,自定义过滤器,加在Spring Boot过滤器链最前端做锁定状态重置的判断(要在数据库表里面加锁定状态的时间点字段)。
- 写一个Spring 的定时器轮询,当然这是最差的方案(要在数据库表里面加锁定状态的时间点字段)。
根据你系统的不同的情况,选择性的实现即可。
9、多种图片验证码实现方案
一、验证码的组成部分
验证码实际上和谜语有点像,分为谜面和谜底。谜面通常是图片,谜底通常为文字。谜面用于展现,谜底用于校验。
- 对于字符型验证码。比如:谜面是显示字符串"ABGH"的图片,谜底是字符串"ABGH"
- 对于计算类验证码。比如:谜面是“1+1=”的图片,谜底是“2”
- 对于拖拽类的验证码。比如:谜面是一个拖拽式的拼图,谜底是拼图位置的坐标
总之,不管什么形式的谜面,最后用户的输入内容要和谜底进行验证。
二、session存储验证码
图中蓝色为服务端、澄粉色为客户端。
这是一种最典型的验证码实现方式,实现方式也比较简单。
- 应用服务端随机的生成验证码文字
- 将验证码文字存到session里面
- 根据验证码文字生成验证码图片,响应给客户端
- 检查用户输入的内容与验证码谜底是否一致
这种实现方式的优点就是比较简单,缺点就是:因为一套应用部署一个session,当我们把应用部署多套如:A、B、C,他们各自有一个session并且不共享。导致的结果就是验证码和图片由A生成,但是验证请求发送到了B,这样就不可能验证通过。
三、共享session存储验证码
在第二小节讲到的问题,实际上不是验证码的问题,而是如何保证session唯一性或共享性的问题。主要的解决方案有两种:
- 通常我们实现负载均衡应用的前端都是使用nginx或者haproxy,二者都可以配置负载均衡策略。其中一种策略就是:你的客户端ip上一次请求的是A应用,你的下一次请求还转发给A应用。这样就保证了session的唯一性。但是这种方式有可能会导致A、B、C应用其中一个或两个分配了大量的请求,而另外一个处理很少的请求,导致负载并不均衡。
- 另外一种非常通用的方式就是将分布式应用的session统一管理,也就是说原来A、B、C各自的session都存在自己的内存中,现在更改为统一存储到一个地方,大家一起用。这样就实现了session的唯一和共享,是实现分布式应用session管理的有效途径。在Spring框架内,最成熟的解决方案就是spring session + redis 。可自行参考实现。
四、基于对称算法的验证码
可能出于主机资源的考虑,可能出于系统架构的考量,有些应用是无状态的。
- 什么是无状态应用:就是不保存用户状态的应用。
- 什么是用户状态:比如当你登陆之后,在session中保存的用户的名称、组织等等信息。
- 所以可以简单的理解,无状态应用就是无session应用。当然这并不完全准确。
那么对于这些无状态的应用,我们就无法使用session,或者换个说法从团队开发规范上就不让使用session。那么我们的验证码该怎么做?
- 同样,首先要生成随机的验证码(谜底),但是不做任何存储操作
- 将谜底(验证码文字)加上时间串、应用信息等组成一个字符串进行加密。必须是对称加密,也就是说可以解密的加密算法。
- 生成验证码图片,并与加密后的密文,通过cookies一并返回给客户端。
- 当用户输入验证码提交登录之后,服务端解密cookies中的密文(主要是验证码文字),与用户的输入进行验证比对。
这种做法的缺陷是显而易见的:实际上就是将验证码文字在客户端服务端之间走了一遍。虽然是加密后的验证码文字,但是有加密就必须有解密,否则无法验证。所以更为稳妥的做法是为每一个用户生成密钥,并将密钥保存到数据库里面,在对应的阶段内调用密钥进行加密或者解密。
从密码学的角度讲,没有一种对称的加密算法是绝对安全的。所以更重要的是保护好你的密钥。正如没有一把锁头是绝对安全的,更重要的是保护好你的钥匙。
10、基于session的图片验证码实现
一、验证码生成之配置使用kaptcha
通过maven坐标引入kaptcha
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
<exclusions>
<exclusion>
<artifactId>javax.servlet-api</artifactId>
<groupId>javax.servlet</groupId>
</exclusion>
</exclusions>
</dependency>
- 假设我们的配置文件是application.yml,新建一个单独的文件叫做kaptcha.properties。因为
kaptcha的配置不符合yaml的规范格式
,所以只能采用properties
。需配合注解PropertySourc使用。 - 假设我们的配置文件是application.properties,将下面这段代码加入进去即可,不用单独建立文件。
- 下面的验证码配置,从英文单词的角度很容易理解,当我们需要调整验证码的边框、颜色、大小、字体等属性的时候,可以修改这些配置。
- kaptcha.properties
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环境下配置文件读取中文乱码,修改如下配置。
二、验证码生成之session保存
生成验证码的Controller。同时需要开放路径"/kaptcha"的访问权限,配置成不需登录也无需任何权限即可访问的路径。如何进行配置,笔者之前的文章已经讲过了。
- 通过captchaProducer.createText()生成验证码文字,并和失效时间一起保存到CaptchaImageVO中。
- 将CaptchaImageVO验证码信息类对象,保存到session中。(这个类的代码后文有介绍)
- 通过captchaProducer.createImage(capText)生成验证码图片,并通过ServletOutputStream返回给前端
@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>
- 实现的效果是,页面初始化即加载验证码。以后每一次点击,都会更新验证码。
- 注意:一定设置width和height,否则图片无法显示。
需要为“/getKaptcha”配置permitAll公开访问权限,否则无法访问到
四、验证码之安全校验
- 编写我们的自定义图片验证码过滤器CaptchaCodeFilter,过滤器中拦截登录请求
- CaptchaCodeFilter过滤器中从seesion获取验证码文字与用户输入比对,比对通过执行其他过滤器链
- 比对不通过,抛出SessionAuthenticationException异常,交给AuthenticationFailureHandler处理
- 最后将CaptchaCodeFilter放在UsernamePasswordAuthenticationFilter表单过滤器之前执行。
@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("验证码不匹配");
}
}
}
- 上面代码中之所以抛出SessionAuthenticationException异常,因为该异常是AuthenticationException的子类,同时也是针对Session数据校验的异常。可以在doFilterInternal中被捕获,交给MyAuthenticationFailureHandler处理。MyAuthenticationFailureHandler 只认识AuthenticationException及其子类。
- codeInRequest是用户请求输入的验证码
- codeInSession是用户请求验证码图片时,保存在session中的验证码谜底。
在MyAuthenticationFailureHandler将异常的message转换为:用户响应的message。即上文异常中定义的:
- 验证码不能为空
- 验证码不存在
- 验证码已经过期
- 验证码不匹配
最后将CaptchaCodeFilter过滤器放到用户名密码登录过滤器之前执行。login.html登录请求中要传递参数:captchaCode