最近在思考如何实现一个权限管理系统。
在查阅一些资料和开源项目后,自己模仿着写了个demo
RBAC(Role-Based Access Control)——基于角色的访问控制
RBAC是一套成熟的权限模型。在传统权限模型中,我们直接把权限赋予用户。而在RBAC中,增加了“角色”的概念,我们首先把权限赋予角色,再把角色赋予用户。这样,由于增加了角色,授权会更加灵活方便。在RBAC中,根据权限的复杂程度,又可分为RBAC0、RBAC1、RBAC2、RBAC3。其中,RBAC0是基础,RBAC1、RBAC2、RBAC3都是以RBAC0为基础的升级。我们可以根据自家产品权限的复杂程度,选取适合的权限模型。
RBAC0是基础,很多产品只需基于RBAC0就可以搭建权限模型了。在这个模型中,我们把权限赋予角色,再把角色赋予用户。用户和角色,角色和权限都是多对多的关系。用户拥有的权限等于他所有的角色持有权限之和。
RBAC1建立在RBAC0基础之上,在角色中引入了继承的概念。简单理解就是,给角色可以分成几个等级,每个等级权限不同,从而实现更细粒度的权限管理。
RBAC2同样建立在RBAC0基础之上,仅是对用户、角色和权限三者之间增加了一些限制。这些限制可以分成两类,即静态职责分离SSD(Static Separation of Duty)和动态职责分离DSD(Dynamic Separation of Duty)。具体限制如下图:
RBAC3是RBAC1和RBAC2的合集,所以RBAC3既有角色分层,也包括可以增加各种限制。
基于RBAC模型,还可以适当延展,使其更适合我们的产品。譬如增加用户组概念,直接给用户组分配角色,再把用户加入用户组。这样用户除了拥有自身的权限外,还拥有了所属用户组的所有权限。
该 demo 是模仿一个开源项目的,是在 RBAC0 基础上进行的一些扩展。该demo的重点是权限管理,所以一些不必要的就不贴出来了。文章末尾会提供源码。以下会分为前端和后端来说明。
这个demo不涉及到按钮的权限控制,按钮的权限控制并不难,这里提供一种思路:
定义一个全局方法,配合 v-if 实现,在用户登录成功后,获取用户的按钮权限(数组格式),存储到store中,定义公共函数hasPermission,在需要权限的按钮中使用即可。
后台用户表,定义了后台用户的一些基本信息。
create table admin
(
id bigint not null auto_increment,
username varchar(64) comment '用户名',
password varchar(64) comment '密码',
icon varchar(500) comment '头像',
email varchar(100) comment '邮箱',
nick_name varchar(200) comment '昵称',
note varchar(500) comment '备注信息',
create_time datetime comment '创建时间',
login_time datetime comment '最后登录时间',
status int(1) default 1 comment '帐号启用状态:0->禁用;1->启用',
primary key (id)
);
后台用户角色表,定义了后台用户角色的一些基本信息,通过给后台用户分配角色来实现菜单和资源的分配。
create table role
(
id bigint not null auto_increment,
name varchar(100) comment '名称',
description varchar(500) comment '描述',
admin_count int comment '后台用户数量',
create_time datetime comment '创建时间',
status int(1) default 1 comment '启用状态:0->禁用;1->启用',
sort int default 0,
primary key (id)
);
后台用户和角色关系表,多对多关系表,一个角色可以分配给多个用户
create table admin_role_relation
(
id bigint not null auto_increment,
admin_id bigint,
role_id bigint,
primary key (id)
);
后台菜单表,用于控制后台用户可以访问的菜单,支持隐藏、排序和更改名称、图标。
create table menu
(
id bigint not null auto_increment,
parent_id bigint comment '父级ID',
create_time datetime comment '创建时间',
title varchar(100) comment '菜单名称',
level int(4) comment '菜单级数',
sort int(4) comment '菜单排序',
name varchar(100) comment '前端名称',
icon varchar(200) comment '前端图标',
hidden int(1) comment '前端隐藏',
primary key (id)
);
后台资源表,用于控制后台用户可以访问的接口,使用了Ant路径的匹配规则,可以使用通配符定义一系列接口的权限。
create table resource
(
id bigint not null auto_increment,
category_id bigint comment '资源分类ID',
create_time datetime comment '创建时间',
name varchar(200) comment '资源名称',
url varchar(200) comment '资源URL',
description varchar(500) comment '描述',
primary key (id)
);
后台资源分类表,在细粒度进行权限控制时,可能资源会比较多,所以设计了个资源分类的概念,便于给角色分配资源。
create table resource_category
(
id bigint not null auto_increment,
create_time datetime comment '创建时间',
name varchar(200) comment '分类名称',
sort int(4) comment '排序',
primary key (id)
);
后台角色菜单关系表,多对多关系,可以给一个角色分配多个菜单。
create table role_menu_relation
(
id bigint not null auto_increment,
role_id bigint comment '角色ID',
menu_id bigint comment '菜单ID',
primary key (id)
);
后台角色资源关系表,多对多关系,可以给一个角色分配多个资源
create table role_resource_relation
(
id bigint not null auto_increment,
role_id bigint comment '角色ID',
resource_id bigint comment '资源ID',
primary key (id)
);
后端是使用 shiro + jwt(java web token)实现的。如果不熟悉shiro建议先学习,同时也需要有jwt的一点知识,这里不作介绍。后端代码不会全部放出来在这,只放出关键的部分。
这里简单提一下登录功能:前端携带账号,密码请求登录接口,登录成功则返回 token(前端将token保存起来,并且每次发起请求都将token放在自定义请求头TOKEN中…)
@PostMapping("/api/login")
public RestResponse<String> login(@RequestBody Map<String, String> params,
HttpServletResponse rsp) {
//不做密码加密了
String userName = params.get("userName");
String password = params.get("password");
Admin admin = adminService.login(userName, password);
if (admin == null) {
return RestResponse.fail(401, "用户名或密码错误!");
} else {
String token = JwtUtil.createToken(admin.getId().toString());
return RestResponse.ok(token);
}
}
继承BasicHttpAuthenticationFilter,如果前端请求头中携带token信息,则进行认证鉴权操作,如果没有携带,则认为是游客
public class JwtFilter extends BasicHttpAuthenticationFilter {
private static final String TOKEN_HEADER = "TOKEN";
Logger log = LoggerFactory.getLogger(JwtFilter.class);
/**
* 如果带有 token,则对 token 进行检查,否则直接通过
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
/*
* 判断用户是否携带token
* 然后进行登录
* (检验token的合法性)
* SignatureVerificationException -----> token 不合法
* TokenExpiredException -----> token 过期
*/
if (isLoginAttempt(request, response)) {
try {
this.executeLogin(request, response);
} catch (Exception e) {
// 认证出现异常,传递错误信息msg
String msg = e.getMessage();
// 获取应用异常(该Cause是导致抛出此throwable(异常)的throwable(异常))
Throwable throwable = e.getCause();
if (throwable != null && throwable instanceof SignatureVerificationException) {
// 该异常为JWT的AccessToken认证失败(Token或者密钥不正确)
msg = "登录异常,请重新登录!-- SignatureVerificationException";
} else if (throwable != null && throwable instanceof TokenExpiredException) {
// 该异常为JWT的AccessToken已过期。让用户重新登录
msg = "请重新登录!-- TokenExpiredException";
} else if (throwable != null && throwable instanceof AuthenticationException) {
msg = "token错误 -- AuthenticationException";
} else {
// 应用异常不为空
if (throwable != null) {
// 获取应用异常msg
msg = throwable.getMessage();
}
}
log.info(msg);
/**
* 错误两种处理方式
* 1. 将非法请求转发到/401的Controller处理,抛出自定义无权访问异常被全局捕捉再返回Response信息
* (或者直接抛出异常,springmvc中有一个默认的错误处理controller,会跳转到/error,然后被那个controller处理)
* 2.无需转发,直接返回Response信息
*/
//this.response401(request, response, msg);
throw e;
}
}
//如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true。
return true;
}
/**
* 判断用户是否想要登入。
* 检测 header 里面是否包含 Token 字段
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader(TOKEN_HEADER); //从请求头中获取token
return token != null;
}
/**
* 执行登陆操作
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws AuthenticationException {
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader(TOKEN_HEADER);
JwtToken jwtToken = new JwtToken(token);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(jwtToken);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}
/**
* 对跨域提供支持。因为跨域请求会先发送一个 Option 请求
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* 无需转发,直接返回Response信息
*/
private void response401(ServletRequest req, ServletResponse resp, String msg) {
HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json; charset=utf-8");
PrintWriter out = null;
try {
out = httpServletResponse.getWriter();
String data = new ObjectMapper().writeValueAsString(RestResponse.fail(401,msg));
out.append(data);
} catch (IOException e) {
// throw new IOException();
} finally {
if (out != null) {
out.close();
}
}
}
}
自定义 Realm,shiro框架中认证鉴权的关键。(从数据库中查询用户的角色,权限信息)
@Component
public class CustomRealm extends AuthorizingRealm {
Logger log = LoggerFactory.getLogger(Logger.class);
@Override
public void setName(String name) {
super.setName("REALM");
}
/**
* 必须重写此方法,不然会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String token = (String) authenticationToken.getCredentials();
String adminId = JwtUtil.getDate(token);
// //token校验
if (adminId == null || !JwtUtil.verify(token, adminId)) {
throw new AuthenticationException("token认证失败!");
}
AdminService adminService = (AdminService) ApplicationContextUtils.getBean("adminServiceImpl");
Admin admin = adminService.getAdminInfo(Long.parseLong(adminId));
if (admin == null) {
throw new AuthenticationException("该用户不存在!");
} else {
return new SimpleAuthenticationInfo(token, token, getName());
}
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//获取用户角色信息,权限信息
String adminId = JwtUtil.getDate(principals.toString());
RoleService roleService = (RoleService) ApplicationContextUtils.getBean("roleServiceImpl");
ResourceService resourceService = (ResourceService) ApplicationContextUtils.getBean("resourceServiceImpl");
//获取用户角色
Set<String> roleSet = roleService.getRolesByAdminId(adminId);
// //获取用户权限字符串
Set<String> permissions = resourceService.getPermissionByUserId(adminId);
info.setRoles(roleSet);
info.setStringPermissions(permissions);
return info;
}
}
shiro相关配置
@Configuration
public class ShiroConfig {
/**
* 先走 filter ,然后 filter 如果检测到请求头存在 token,则用 token 去 login,走 Realm 去验证
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
// 添加自己的过滤器并且取名为jwt
Map<String, Filter> filterMap = new LinkedHashMap<>();
//设置我们自定义的JWT过滤器
filterMap.put("jwt", new JwtFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
Map<String, String> filterRuleMap = new HashMap<>();
filterRuleMap.put("/api/login", "anon");
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/**", "jwt");
//如果有静态资源,要开放静态资源
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
/**
* 注入 securityManager
*/
@Bean("securityManager")
public DefaultWebSecurityManager securityManager(CustomRealm customRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置自定义 realm.
securityManager.setRealm(customRealm);
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
// 设置自定义Cache缓存
// securityManager.setCacheManager(new RedisCacheManager());
return securityManager;
}
/**
* 添加注解支持
*/
@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
// https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
}
@RestController
@RequiresRoles(value = {"商品管理员", "超级管理员"},logical=Logical.OR)
public class GoodsController {
//品牌管理
@RequiresPermissions(value = {"brand:*"})
@GetMapping("/api/goods/brand")
public RestResponse<String> brandManager() {
return RestResponse.ok("品牌管理");
}
//商品管理
@RequiresPermissions(value = {"product:*"})
@GetMapping("/api/goods/product")
public RestResponse<String> productManager() {
return RestResponse.ok("商品管理");
}
//商品属性管理
@RequiresPermissions(value = {"productAttribute:*"})
@GetMapping("/api/goods/productAttribute")
public RestResponse<String> productAttributeManager() {
return RestResponse.ok("商品属性管理");
}
}
可以改进的地方有很多,比如:
可以把查询到的用户角色,权限信息,放置到redis中,而不用每次都查询
接口注解上的角色和权限是写死的,感觉这样并不容易扩展,考虑下次再用 sping security
实现一遍
还有对异常的全局处理等等…
用到 vue 两个重要的知识,Vue-Router 和 Vuex ,还有Promise ,async await ,不熟练的建议了解后再看,不然头会晕妥妥的。
在src/views/login/index.vue
中,输入用户名,密码点击登录
handleLogin() {
this.$store
.dispatch('Login', this.loginForm)
.then(() => {
console.log('登录成功')
this.$router.push({ path: '/' })
})
.catch(() => {
console.log('登录失败')
})
},
在src/store/modules/user.js
中,发起登录请求。登录成功后将token保存起来。
// 登录
Login({ commit }, userInfo) {
const username = userInfo.userName.trim()
return new Promise((resolve, reject) => {
_LoginApi
.login({ userName: username, password: userInfo.password })
.then((response) => {
const tokenStr = response.response
setToken(tokenStr)
commit('SET_TOKEN', tokenStr)
resolve()
})
.catch((error) => {
reject(error)
})
})
},
设置axios请求拦截器,每次请求后端接口携带token
// 请求拦截器 带上token
axios.interceptors.request.use(
function(config) {
// 在发送请求之前做些什么,例如加入token
let token = getToken()
if (token != null && token != '') {
config.headers.TOKEN = token
}
return config
},
function(error) {
// 对请求错误做些什么
return Promise.reject(error)
}
)
首先我们需要修改src/router/index.js
中的路由表,将路由表进行拆分,拆分成必须要显示的静态路由表
和可以动态显示的动态路由表
。
然后我们需要添加src/store/modules/permission.js
文件,在Vuex的Store中添加权限相关状态,比如和左侧菜单绑定的路由表。
这里有个比较核心的GenerateRoutes方法,用于生成当前用户可以访问的路由。我们的data参数中包含了用户可以访问的菜单信息。它的具体执行流程如下:从菜单信息中筛选出可以访问的动态路由,然后进行排序,最后提交状态改变到Vuex中去改变routers这个状态。
GenerateRoutes({ commit }, data) {
return new Promise((resolve) => {
const { menus } = data
const { username } = data
const accessedRouters = asyncRouterMap.filter((v) => {
//admin帐号直接返回所有菜单
// if(username==='admin') return true;
if (hasPermission(menus, v)) {
if (v.children && v.children.length > 0) {
v.children = v.children.filter((child) => {
if (hasPermission(menus, child)) {
return child
}
return false
})
return v
} else {
return v
}
}
return false
})
//对菜单进行排序
sortRouters(accessedRouters)
commit('SET_ROUTERS', accessedRouters)
resolve()
})
},
关于前端路由和后台菜单的匹配,其实是根据路由名称和菜单的前端名称来确定的,比如商品列表中的路由名称和menu表中存储的前端名称如下。
接下来我们需要修改src/store/index.js
文件,在Vuex的Store中添加这个权限模块的状态。
我们还需要修改src/components/nav/index.vue
文件,将左侧菜单组件和Vuex中存储的路由状态进行绑定,这样当我们修改了Vuex中的状态后,菜单就会改变了。mapGetters是个辅助函数,可以将Store中的Getter属性映射到局部计算属性。
最后我们需要在用户登录成功后,通过store.dispatch('GenerateRoutes', { menus,username })
来修改Vuex中存储的路由状态并传入用户可以访问的菜单信息。(在src/perission.js
中)
router.beforeEach((to, from, next) => {
//改变title
if (to.meta.title) {
document.title = to.meta.title
}
if (getToken()) {
if (to.path === '/login') {
next({ path: '/' })
} else {
if (store.getters.roles.length === 0) {
store
.dispatch('GetInfo')
.then((res) => {
// 拉取用户信息
let menus = res.response.menus
let username = res.response.username
store
.dispatch('GenerateRoutes', { menus, username })
.then(() => {
// 生成可访问的路由表
router.addRoutes(store.getters.addRouters) // 动态添加可访问路由表
next({ ...to, replace: true })
})
})
.catch((err) => {
store.dispatch('FedLogOut').then(() => {
Message.error(
err || 'Verification failed, please login again'
)
next({ path: '/' })
})
})
} else {
next()
}
}
} else {
if (whiteList.indexOf(to.path) !== -1) {
next()
} else {
next('/login')
}
}
})
记得在 main.js
中引入以下permission.js
import '@/permission' // permission control
对 Promise 语法还不是很熟悉,看了很久的项目源码才吸收前端的内容,然后东改西改才完成。对前端知识掌握的还不够
不同角色登录的用户拥有的菜单和权限是不同的。
还有很多改善的地方。对于登录功能,还要考虑 token 的刷新,还有权限还不够系,还可以做到更细粒度的。
还有对异常的处理,demo并没有处理好。
码云