vue-element-admin和ruoyi-vue权限控制

上份工作是做权限管理的项目,做过前端也做过后端,过程中只是对权限管理有一定了解,认证和鉴权具体实现并不清楚。利用闲暇时间学习 vue-element-adminRuoYi-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 组件遍历路由生成该角色的菜单栏导航。 具体实现过程描述如下:

  1. 创建vue实例的时候将vue-router挂载,但这个时候vue-router挂载一些登录或者不用权限的公用的页面。
  2. 当用户登录后,获取用户role,将role和路由表每个页面的需要的权限作比较,生成最终用户可访问的路由表。
  3. 调用router.addRoutes(store.getters.addRouters)添加用户可访问的路由。
  4. 使用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),具体流程如下:

  1. 验证码校验:根据 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();
    }
}
  1. 用户名和密码校验:将用户名和密码传递给 spring security,spring security 调用 UserDetailsServiceloadUserByUsername 方法。该方法需要重写,自定义了用户名校验方法和限制同一账号多次重试,同一账号最后返回的类型为 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)));
    }
}
  1. 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 时,认证流程又有所不同,具体流程如下:

  1. token 解析:解析 token 拿到 uuid,根据 uuid 从 redis 中获得用户信息,如果 redis 中的缓存过期 securityContext 则没办法设置用户信息导致认证失败
  2. 权限设置:从 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));
    }
}

参考:

权限系统设计模型分析

一文带你搞定页面权限、按钮权限以及数据权限

手摸手,带你用vue撸后台 系列二(登录权限篇)

ruoyi权限控制

  • 29
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值