基于CASL的前端应用权限管理方案

6,784 阅读8分钟

在一个拥有角色的应用中,避免不了要管理每种角色的权限,比如在一个博客系统游客只能查看文章,注册用户可编辑自己的文章和查看别人发的文章,在前端要实现这样的功能可能充斥着类似代码:

if (user && article.owner === user.id)
{
    // Create & Update & Delete & Read permissions
}
else
{
    // Read permissions
}

不仅如此,当页面元素需要根据角色来决定是否展示时我们也要写一大堆的判断逻辑,十分的繁琐。当项目的权限比较复杂时,代码会变的十分难维护,还需要一份文档来说明一下什么角色拥有什么权限,时间一久,祖传代码自己都看不懂。

其实以上的问题归根结底都是角色和权限的关系没有理清楚,如果我们把角色和权限整理出一张对应表,作为规则维护在store中,当需要权限判断时都去校验一下规则,事情不就变的简单了,如下

const rules = {
    // 游客
    tourists: [{
        action: 'read',
        subject: 'Article',
    }],
    // 注册用户
    user: [{
            action: 'read',
            subject: 'Article'
        },{
            action: 'update',
            subject: 'Article',
            conditions: {
                published: true,
            }
        }]
}

然后我们在封装一个can方法用于规则校验

/**
 * 判断content跟conditions中的所有属性是否都能匹配上
 * @param conditions - 规则需要满足的条件 
 * @param content - 需要检测的内容
 */
function checkConditions(conditions, content)
{
    if (!conditions) return;
    const result = true
    for(let key in conditions)    {
        if (conditions[key] !== content[key])
        {
            result = false
        }
    }

    return result
}

/**
 * 检测操作是否允许
 * role - 用户的角色
 * action - 需要进行规则检测的操作
 * subject - 需要进行规则检测的主题
 * conditions - 需要进行规则检测的条件
 */
function can(role, action, subject, conditions)
{
    if (role === 'tourists')
    {
        return !!(rules['tourists'].find(item => item.action === action && item.subject === subject && checkConditions(item.conditions, conditions)))    }
    else if (role === 'user')
    {
        return !!(rules['user'].find(item => item.action === action && item.subject === subject && checkConditions(item.conditions, conditions))    }
}

使用can方法进行检测

// 检测是否可以阅读文章
if (can(user.role, 'read', 'Article'))
{
    // do something
    ...
}

// 检测是否可以编辑文章
if (can(user.role, 'update', 'Article', {published: article.published}))
{
    // edit operation
    ...
}

em...规则是有了,但是用起来还是有点麻烦。别急,我们再优化一下

把规则表存到后端,通过登录接口返回。即用户登录后,后端根据角色返回规则

cosnt users = await login();
const rules = users.rules

// 相当于
rules = [{
    action: 'read',
    subject: 'Article'
},{
    action: 'update',
    subject: 'Article',
    conditions: {
        published: true,
    }
}]

然后将 can 的 subject 和 conditions 参数合并一下,这样我们的 can 函数就可以简化成

/*
 * 检测操作是否允许
 * @param action - 需要进行规则检测的操作
 * @param content - 需要进行规则检测的内容,可以是string或object
 */
function can(action, content)
{
    const subject = typeof content === 'string' ? content : content.type
    const conditions = typepf content === 'string' ? {} : content
    return rules.find(item => item.action === action && item.subject === subject && checkConditions(item.conditions, conditions))
}

使用can方法进行检测

// 检测是否可以阅读文章
if (can('read', 'Article))
{
    // do something
    ...
}


// 检测是否可以编辑文章
if (can('update', {type: 'Article', published: article.published}))
{
    // edit operation
    ...
}

是不是简单了很多!这样后端负责管理不同角色的规则, 前端只需要根据规则写业务。这么有用的功能,当然有第三方库啦,CASL就是一个实现了以上功能的第三方库

什么是CASL

CASL是一个授权JavaScript库,通过它我们可以定义给定类型的用户可以访问哪些资源。

CASL 官网: casl.js.org/v4/en/guide…

要使用CASL先了解下面这几个概念:

Action:  

规则允许的操作,通常取值有 create、read、update、delete、manage(相当于create + read + update + delete),如果想要组合多个action或使用自定义的action名称可以给action设置alias

Subject: 

规则的主题或主题类型。通常这是一个业务实体,比如Article,User等。

Fields:  

用于限制用户可以访问那些字段,比如允许版主更新文章的隐藏字段,但不更新描述或标题。

Conditions:

限制条件,可以是一个对象或函数,只有匹配上Conditions的规则才会生效, 比如,允许用户更新和删除自己的文章。

看这几个概念是不是很眼熟,其实上面说的can函数就是为了让大家提前对这几个概念有初步的认识,他只是casl实例中can方法的超级简陋版本,再看一下下面几个例子,相信看完你会惊叹这才是真正的can。

例一:

a. 允许用户做任何事情,除了删除 Article

import {defineAbility } from '@casl/ability';

/*
 * 这里 manage 和 delete 是 Action, all 和 Article 是 Subject
 * manage 和 all 都是 CASL 中的特殊关键字, manage 代表所有的 Action, all 代表所有的 Subject
 */
const ability = defineAbility((can, cannot) => {
  can('manage', 'all');
  cannot('delete', 'Article');
});

ability.can('read', 'User'); // true
ability.can('delete', 'User'); // true
ability.can(‘delete’, ‘Article’); // false

例二:

a. 可以读所有的 Article b. 可以更新自己的 Article

import { defineAbility } from '@casl/ability';

class Entity {
  constructor(attrs) {
    Object.assign(this, attrs);
  }
}
class Article extends Entity {}

function defineAbilityFor(user) {
  return defineAbility((can) => {
    can('read', 'Article');

    if (user.isLoggedIn) {
      can('update', 'Article', { authorId: user.id });
    }
  });
}

const user = { id: 1, isLoggedIn: true };
const ownArticle = new Article({ authorId: user.id });
const anotherArticle = new Article({ authorId: 2 });
const ability = defineAbilityFor(user);

ability.can('read', 'Article') // true
ability.can('update', 'Article') // true
ability.can('update', ownArticle) // true
ability.can('update', anotherArticle) // false

例三: 

a. 用户可以读所有的Article

b. 用户可以更新自己 Articletitledescription 字段

c. 只用版主可以更新已经 publishedArticle

import { defineAbility } from '@casl/ability';

class Article {
    constructor(attrs) {
        Object.assign(this, attrs);
    }
}
export default function defineAbilityFor(user) {
  return defineAbility((can) => {
    can('read', 'Article');
    can('update', 'Article', ['title', 'description'], { authorId: user.id })

    if (user.isModerator) {
      can('update', 'Article', ['published'])
    }
  });
}

const moderator = { id: 2, isModerator: true };
const ownArticle = new Article({ authorId: moderator.id });
const foreignArticle = new Article({ authorId: 10 });
const ability = defineAbilityFor(moderator);

ability.can('read', 'Article') // true
ability.can('update', 'Article', 'published') // true
ability.can('update', ownArticle, 'published') // true
ability.can('update', foreignArticle, 'title') // false

例四: 

a. 用户可以读所有的Article

import { defineAbility } from '@casl/ability';

class Article {
    constructor(attrs) {
        Object.assign(this, attrs);
    }
}
const ability = defineAbility((can) => {
  can('read', 'Article', { published: true }) // (0)
});
const article = new Article({ published: true });

ability.can('read', article); // (1)true
ability.can('read', 'Article'); // (2)true

这里有两个需要注意的地方

  • (1)和(2)有什么区别?

(1)表示"can I read this article?",(2)表示"can I read at least one article?", 我们的用户可以阅读已发表的文章,所以它至少可以阅读一篇已发表的文章,所以都为true

  • (1)中的article是怎么跟  (0) 中的 'Article' 对应起来的

如果你传递一个对象作为ability.can中的第二个参数。CASL获取object.constructor。modelName作为Subject type,如果这个不可用,它将回退到object.constructor.name。由于article.constructor.name 为 "Article",所以可以匹配上规则。当然你可以自定义类型检测算法 ,这个后面会讲。

看完上面的例子是不是觉得还是挺简单的,预定义一套规则,然后使用ability.can方法来进行校验就好了,确实,简单的应用可以直接在代码初始化的时候就把规则订好,但是很多情况下,我们的规则是动态的,比如重新登录后,角色改变,我们需要更新规则。下面我们详细来讲一下Casl是怎样在React应用中管理权限的。

CASL React

首先安装一下依赖,react中我们需要装两个@casl/react@casl/ability@casl/react是CASL的核心,它包含负责检查和定义权限的逻辑。@casl/ability 允许将@casl/ability与React应用程序集成。它提供了Can组件,允许根据用户查看UI元素的能力隐藏或显示它们。

npm install @casl/react @casl/ability
# or
yarn add @casl/react @casl/ability
# or
pnpm add @casl/react @casl/ability

然后通过 AbilityContext.Provider给代码提供一个ability实例

import { createContext } from 'react';
import { createContextualCan } from '@casl/react';
import { Ability, detectSubjectType } from '@casl/ability';
export const AbilityContext: any = createContext({});
export const Can = createContextualCan(AbilityContext.Consumer);

/**
 * 自定义自己的类型检测算法
 */
function getSubjectName(subject)
{
    if (subject && typeof subject === 'object' && subject.type)
    {
        reutrn subject.type
    }
    return detectSubjectType(subject)
}

// 默认我们禁用了所用能力,之后通过登陆接口获得真正的rule时,我们再更新ability
export let ability = new Ability([{
    action: 'manage',
    subject: 'all',
    inverted: true,
}], {
    detectSubjectType: getSubjectName
});

export default function App({ props }) {
  return (
    <AbilityContext.Provider value={ability}>
      <Text />
    </AbilityContext.Provider>
  )
}

嗯?前面定义一个ability实例使用的是defineAbility方法,这里怎么不一样了。

其实Casl有三种定义ability的方法,我们这里使用JSON对象直接new了一个Ability实例,效果跟defineAbility一样,但是再跟后端配合实现动态规则时会更加灵活,后端只需要传回一个JSON对象,我们就可以直接更新ability了

CASL还提供detectSubjectType选项来覆盖内置算法,如上面的getSubjectName就会处理subject为object时的情况,当subject为object时,取subject.type作为subject类型,这是跟后端的同事协商好的,如后端返回Article时需要带上type属性。

在登录接口中更新ability实例规则

async function login()
{
    try
    {
        const user = await signIn(); // 调用登录api获取用户信息
        ability.update(user.rules);  // 后端传回的rules后更新ability实例规则
    }
    catch(error)
    {
        console.error('login error')
    }
}

登录成功后获得用户信息,同时后端传回该user的对应的rules,这时我们就可以更新ability实例规则了,user.rules是一个对象数组, 格式如下

// user
{
    ...
    rules: [{
        action: 'read',
        subject: 'Article',
    }, {
        action: 'update',
        subject: 'Article',
        conditions: {
            published: true,
        }
    }]
    ....
}

还记得之前export的Can组件吗,直接使用Can你就可以在render中进行规则校验了

检测是否可以阅读文章

import React, { Component } from 'react'
import { Can } from './App'

export class Text extends Component {
  render() {
    return (
      <Can I="read" a="Article">
        ...
      </Can>
    )
  }
}

检测是否可以编辑文章

import React, { Component } from 'react'
import { Can } from './App'

export class Text extends Component {
  constructor(props) {
    super(props);
    this.state = { article: {} }
  }

  componentDidMount()
  {
    this.getArticle()
  }

  async getArticle()
  {
    // 调用后端api获取 article
    // 后端返回的article中要带上type,这是之前定义ability实例时就协商好的,如{ type: Article, published: true, content: '...' }
    await article = fetch('path/to/api/article')
    this.setState({ article })
  }

  render() {
    return (
      <Can I="update" a={this.state.article}>
        <button>update</button>
      </Can>
    )
  }
}

也可以使用context来进行规则判断

import React, { Component } from 'react'
import { AbilityContext } from './App'

export class Text extends Component {
  render() {
    return (
      <div>
        {this.context.can('read', 'Article') && <div>...</div>}
      </div>
    );
  }
}

Text.contextType = AbilityContext;

还可以直接使用 ability 实例

import React, { Component } from 'react'
import { ability } from './App'

export class Text extends Componets {
  render() {
    return (
      <div>
        {ability.can('read', 'Article') && <div>...</div>}
      </div>
    );
  }
}

需要注意的是使用ability.update更新实例是不会触发react重新渲染的,所以后面两个例子(不使用Can组件)在ability.update后是不会触发UI更新的。这种情况下,您需要创建一个新的ability实例来更新规则。

以上就是本文的全部内容,希望能帮助你更好的管理应用的权限。