上份工作是做权限管理的项目,做过前端也做过后端,过程中只是对权限管理有一定了解,认证和鉴权具体实现并不清楚。利用闲暇时间学习 vue-element-admin 和 RuoYi-Vue 来弥补和完善整个权限认证体系。
权限模型
先回顾总结下之前做权限设计的经验。权限设计的核心在于怎么合理的给每个人分配权限,其中的核心我理解有三个:用户,资源,策略。用户的主体是人,资源可以是页面/按钮/api等,策略是指定用户应该按照何种规访问哪些资源。权限模型总的来说可以分为5类:
-
ACL:Access Control List,访问控制列表。简单理解为将资源的访问权限记录下,资源能否访问先查表,window的文件系统就是这种模式的应用。
-
DAC:Discretionary Access Control,自主访问控制(ACL的拓展)。简单理解为在 ACL 基础上,拥有权限的用户自主的给其他人赋予资源的访问权限。
-
MAC: Mandatory Access Control,强制访问控制。简单理解为用户和资源都设置了权限限制,用户访问资源需要验证用户身份和资源的访问级别。
-
RBAC:Role-Based Access Control,基于角色的权限访问控制。简单理解为给用户赋予特定的角色,角色上赋予资源的访问权限。RBAC目前是主流的权限控制模型,细分为RBAC0,RBAC1,RBAC2,RBAC3。
-
ABAC:Attribute-Based Access Control,基于属性的访问控制。简单理解为通过策略(访问规则的描述)来限定资源的访问,策略可以用在用户上,也可以用在资源上。
// 阿里云 RAM 策略配置表
{
"Version": "1",
"Statement":[{
"Effect": "Allow",
"Action": ["oss:List*", "oss:Get*"], // 请求的描述
"Resource": ["acs:oss:*:*:samplebucket", "acs:oss:*:*:samplebucket/*"], // 资源的描述
"Condition": // 约束条件的描述
{
"IpAddress":
{
"acs:SourceIp": "42.160.1.0"
}
}
}]
}
RBAC 权限模型
RBAC 作为主流的权限控制模型有3个基础组成部分,分别是:用户、角色和权限:
- 用户:可以是单个用户,也可以是用户组
- 角色:可以定义为单个角色,也可以将同类型的角色做成角色集,上下级的角色定义为岗位
- 权限:可以分为两大类:功能权限和数据权限。功能权限是指菜单(页面),按钮(api)等这类权限。数据权限是指对数据访问范围的区分,如根据国家区分数据的访问范围。
RBAC 模型分类
RBAC0:最基础 RBAC 模型,。在这个模型中,我们把权限赋予角色,再把角色赋予用户。用户和角色,角色和权限都是多对多的关系。用户拥有的权限等于他所有的角色持有权限之和。
RBAC1:RBAC1建立在RBAC0基础之上,在角色中引入了继承的概念。简单理解就是,给角色可以分成几个等级,每个等级权限不同,从而实现更细粒度的权限管理。角色集和岗位就是RBAC1的应用。
RBAC2:RBAC2同样建立在RBAC0基础之上,对用户、角色和权限三者之间增加了一些限制。这些限制可以分成两类,即静态职责分离SSD(Static Separation of Duty)和动态职责分离DSD(Dynamic Separation of Duty)。
RBAC3:RBAC3 = RBAC1 + RBAC2,所以RBAC3既有角色分层,也包括可以增加各种限制。
前端功能权限实现
对于前端来说,权限控制体现在页面和按钮是否显示。结合 vue-element-admin 看看页面的显示控制是怎么完成的。
在 vue-element-admin 中,页面的显示控制是获取到角色后,找到 route.meta.roles
符合的角色生成 asyncRoutes
通过 router.addRoutes
动态生成 routes
。侧边栏 SideBar
组件遍历路由生成该角色的菜单栏导航。 具体实现过程描述如下:
- 创建vue实例的时候将vue-router挂载,但这个时候vue-router挂载一些登录或者不用权限的公用的页面。
- 当用户登录后,获取用户role,将role和路由表每个页面的需要的权限作比较,生成最终用户可访问的路由表。
- 调用router.addRoutes(store.getters.addRouters)添加用户可访问的路由。
- 使用vuex管理路由表,根据vuex中可访问的路由渲染侧边栏组件。
页面功能权限
页面权限功能的总体逻辑在beforeEach,代码如下:
router.beforeEach(async(to, from, next) => {
// start progress bar
NProgress.start()
// set page title
document.title = getPageTitle(to.meta.title)
// determine whether the user has logged in
const hasToken = getToken()
if (hasToken) {
if (to.path === '/login') {
// if is logged in, redirect to the home page
next({ path: '/' })
NProgress.done() // hack: https://github.com/PanJiaChen/vue-element-admin/pull/2939
} else {
// determine whether the user has obtained his permission roles through getInfo
const hasRoles = store.getters.roles && store.getters.roles.length > 0
if (hasRoles) {
next()
} else {
try {
// get user info
// note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
const { roles } = await store.dispatch('user/getInfo')
// generate accessible routes map based on roles
const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
// dynamically add accessible routes
router.addRoutes(accessRoutes)
// hack method to ensure that addRoutes is complete
// set the replace: true, so the navigation will not leave a history record
next({ ...to, replace: true })
} catch (error) {
// remove token and go to login page to re-login
await store.dispatch('user/resetToken')
Message.error(error || 'Has Error')
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
} else {
/* has no token*/
if (whiteList.indexOf(to.path) !== -1) {
// in the free login whitelist, go directly
next()
} else {
// other pages that do not have permission to access are redirected to the login page.
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
登陆的时序简易图如下:
细节说明:每个路由可以让哪些角色访问是写死在前端代码中的,不支持动态配置。如果需要支持动态配置,可以在前端通过一个 tree 控件或者其它展现形式给管理员配置角色的路由表,之后将这份路由表存储到后端。当用户登录后得到 roles
,前端根据roles
去向后端请求可访问的路由表,从而动态生成可访问页面,之后就是 router.addRoutes
动态挂载到 router
上。
按钮功能权限
通过获取到用户的role之后,在前端用v-if手动判断来区分不同权限对应的按钮的。需要按钮权限主要是为了限制一些修改,新增或者删除操作,对于这种需求后端来进行会更加安全。
<template>
<!-- Admin can see this -->
<el-tag v-permission="['admin']">admin</el-tag>
<!-- Editor can see this -->
<el-tag v-permission="['editor']">editor</el-tag>
<!-- Editor can see this -->
<el-tag v-permission="['admin','editor']">Both admin or editor can see this</el-tag>
</template>
<script>
// 当然你也可以为了方便使用,将它注册到全局
import permission from '@/directive/permission/index.js' // 权限判断指令
export default{
directives: { permission }
}
</script>
v-permission 的指令实现部分:
function checkPermission(el, binding) {
const { value } = binding
const roles = store.getters && store.getters.roles
if (value && value instanceof Array) {
if (value.length > 0) {
const permissionRoles = value
const hasPermission = roles.some(role => {
return permissionRoles.includes(role)
})
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
}
} else {
throw new Error(`need roles! Like v-permission="['admin','editor']"`)
}
}
export default {
inserted(el, binding) {
checkPermission(el, binding)
},
update(el, binding) {
checkPermission(el, binding)
}
}
后端端功能权限实现
登陆认证
对于后端来说,登陆验证主要解决两件事情:首次登陆确认用户名和密码,返回一个登陆凭证 token ;携带 token 时检验 token 是否有效和是否过期。
先看下从登陆页面发出的请求的逻辑。前端登陆接口为 /login ,/register
时请求头是不带token的,这点从源码可以看出。
export function login(username, password, code, uuid) {
const data = {
username,
password,
code,
uuid
}
return request({
url: '/login',
headers: {
isToken: false
},
method: 'post',
data: data
})
}
// 注册方法
export function register(data) {
return request({
url: '/register',
headers: {
isToken: false
},
method: 'post',
data: data
})
}
// axios 请求拦截器
service.interceptors.request.use(config => {
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false
if (getToken() && !isToken) {
config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
}
}
ruoyi-vue 分离版权限控制使用的是 spring security(关于 spring security 的使用可以看这篇文章:link),具体流程如下:
- 验证码校验:根据 uuid 从 redis 取出验证码,和前端传递的比对是否正确
// 验证码开关
if (captchaEnabled){
validateCaptcha(username, code, uuid);
}
/**
* 校验验证码
*
* @param username 用户名
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public void validateCaptcha(String username, String code, String uuid){
String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
String captcha = redisCache.getCacheObject(verifyKey);
redisCache.deleteObject(verifyKey);
if (captcha == null){
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha)){
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
throw new CaptchaException();
}
}
- 用户名和密码校验:将用户名和密码传递给 spring security,spring security 调用
UserDetailsService
的loadUserByUsername
方法。该方法需要重写,自定义了用户名校验方法和限制同一账号多次重试,同一账号最后返回的类型为UserDetails
。通过后会返回Authentication
对象,该对象包括三个属性Principal
用户信息,没有认证时一般是用户名,认证后一般是用户对象;Credentials
用户凭证,一般是密码;Authorities
用户权限。校验失败后会调用AuthenticationEntryPoint
接口的commence
方法。
// 用户验证
Authentication authentication = null;
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
SysUser user = userService.selectUserByUserName(username);
if (StringUtils.isNull(user)){
log.info("登录用户:{} 不存在.", username);
throw new ServiceException("登录用户:" + username + " 不存在");
}else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()){
log.info("登录用户:{} 已被删除.", username);
throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
}else if (UserStatus.DISABLE.getCode().equals(user.getStatus())){
log.info("登录用户:{} 已被停用.", username);
throw new ServiceException("对不起,您的账号:" + username + " 已停用");
}
// 登录账户密码错误次数校验
passwordService.validate(user);
return createLoginUser(user);
}
/**
* 认证失败处理类 返回未授权
*
* @author ruoyi
*/
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable{
private static final long serialVersionUID = -8970718410437077606L;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)throws IOException{
int code = HttpStatus.UNAUTHORIZED;
String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
}
}
- token 生成:从
Authentication
得到用户信息,生成 uuid 作为键,用户信息作为值保存到 redis 中。生成的 uuid 也保存到 token 的 body 中。
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 生成token
return tokenService.createToken(loginUser);
/**
* 创建令牌
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser){
String token = IdUtils.fastUUID();
loginUser.setToken(token);
// 刷新 redis 令牌有效期
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
/**
* 刷新令牌有效期
*
* @param loginUser 登录信息
*/
public void refreshToken(LoginUser loginUser){
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
private String createToken(Map<String, Object> claims){
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
不携带 token 登陆认证时序图如下:
当请求携带 token 时,认证流程又有所不同,具体流程如下:
- token 解析:解析 token 拿到 uuid,根据 uuid 从 redis 中获得用户信息,如果 redis 中的缓存过期 securityContext 则没办法设置用户信息导致认证失败
- 权限设置:从 redis 取出的用户信息中包含用户的权限数据,会在本次请求中设置到 securityContext
/**
* token过滤器 验证token有效性
*
* @author ruoyi
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter{
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
{
tokenService.verifyToken(loginUser);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}
登陆认证时序图如下:
接口鉴权
接口鉴权部分目前还没有自己敲过,先按照文档权限注解和参考讲义来进行说明。
Spring Security提供了Spring EL表达式,允许我们在定义接口访问的方法上面添加注解,来控制访问权限。因此可以看到在需要被控制的接口上存在注解@PreAuthorize("@ss.hasPermi('system:role:list')")
,该注解表示调用该接口是需要权限的,并且权限为“system:role:list”。
@PreAuthorize("@ss.hasPermi('system:role:list')")
@GetMapping("/list")
public TableDataInfo list(SysRole role)
{
startPage();
List<SysRole> list = roleService.selectRoleList(role);
return getDataTable(list);
}
@PreAuthorize
注解是 SpringSecurity 框架的注解,注解的value值需要填写一个表达式,如果表达式计算的结果是true,那么就允许访问对应的接口,如果表达式计算的结果是false,则表示没有权限。当我们需要用到该注解时,需要先写另一个注解来让其能生效,ry程序在 SecurityConfig
类已经写了,如下:
/**
* spring security配置
*
* @author ruoyi
*/
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter{
// 省略
}
@EnableGlobalMethodSecurity
注解后面的 prePostEnabled
方法(Determines if Spring Security’s pre post annotations should be enabled. Default is false.)表示 @PreAuthorize
和 @PostAuthorize
注解要不要启用。@PreAuthorize
表示在执行方法前验证权限,@PostAuthorize
表示在执行方法后验证权限。
@PreAuthorize
注解的值为 SpEL 表达式,SpEL 表达式能写方法调用,通过“@”来引用bean。@ss.hasPermi('system:post:list')
引用名为 ss 的 Bean,然后调用该 Bean 的 hasPermi 方法,并传入了参数 ‘system:post:list’。Bean ss 定义如下:
/**
* RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母
*
* @author ruoyi
*/
@Service("ss")
public class PermissionService
{
/** 所有权限标识 */
private static final String ALL_PERMISSION = "*:*:*";
/** 管理员角色权限标识 */
private static final String SUPER_ADMIN = "admin";
private static final String ROLE_DELIMETER = ",";
private static final String PERMISSION_DELIMETER = ",";
/**
* 验证用户是否具备某权限
*
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
public boolean hasPermi(String permission)
{
if (StringUtils.isEmpty(permission))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
{
return false;
}
PermissionContextHolder.setContext(permission);
return hasPermissions(loginUser.getPermissions(), permission);
}
/**
* 判断是否包含权限
*
* @param permissions 权限列表
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
private boolean hasPermissions(Set<String> permissions, String permission)
{
return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
}
}
参考: