Skip to content
On this page

访问控制

访问控制顾名思义就是“谁可以做这件事”,这在智能合约世界中是非常重要的。智能合约中的访问控制可以规定谁可以mint tokens,谁可以给提案投票,冻结转账等类似的事情。因此了解如何实现至关重要,以免其他人窃取你的整个系统。

所有权和Ownable

最基础和常见的访问控制就是所有权的概念:智能合约会有一个账户,它是这个合约的owner,并且可以对其执行管理任务。这个方式对于合约这种单一管理员来说是非常合理的。
OpenZeppelin合约提供了Ownerable来实现合约中的所有权管理。

solidity
// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/access/Ownable.sol";

contract MyContract is Ownable {
    function normalThing() public {
        // anyone can call this normalThing()
    }

    function specialThing() public onlyOwner {
        // only the owner can call specialThing()!
    }
}

通常情况下,部署合约的账号就是合约的所有者Ownerable标签允许你:

警告

完全删除所有者,也就意味着标记onlyOwner标签的函数将不可调用

请注意,合约也可以是另一个合约的拥有者!这打开了使用的大门,比如Gnosis Multisig,或Gnosis Safe,Aragon DAO,一个 ERC725/uPort身份认证合约,或者是你自己完全自定义的合约。 通过这种方式,你可以在合约中添加更复杂的访问控制层。替代单一的以太坊账户,你可以使用2/3多重签名方式来主导项目运行,例如著名的MarkerDAO就是使用了类似的系统。

基于角色的访问控制

虽然所有权方案对于简单系统或者快速构建原型很有效,但通常情况下,多层级的访问控制也是需要的。你可能需要一个账户来禁止用户使用系统,但是又不想让它创建新的token。基于角色的访问控制在这方面提供了灵活性。
实际上,我们会定义多个角色,每个角色对应一组操作权限。举例来说,某个账户拥有'moderator','miner'或'admin'角色,这样你就替换onlyOwner来进行权限查验了。这个检查可以通过onlyRole修饰符来进行。另外,你还可以定义如何向账户授权,撤销权限等规则。
多数软件都采用基于角色的访问控制:一些用户是普通用户,一些用户是主管或经理,还有少数一些是具有管理权限。

使用AccessControl

OpenZeppelin合约提供了AccessControl来实现基于角色的访问控制。它的使用非常简单:对于每个定义的角色,只需要创建一个角色标识符,用来授权,撤销和检查账户是否拥有此角色。 下面是ERC20 Token使用AccessControl的简单例子,它定义了一个'minter'角色,这个角色允许账户创建新的token。

solidity
// contracts/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20, AccessControl {
    // Create a new role identifier for the minter role
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor(address minter) ERC20("MyToken", "TKN") {
        // Grant the minter role to a specified account
        _setupRole(MINTER_ROLE, minter);
    }

    function mint(address to, uint256 amount) public {
        // Check that the calling account has the minter role
        require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter");
        _mint(to, amount);
    }
}

注意

请在使用AccessControl前充分理解它的工作机制,或者简单些直接将示例中的代码拷贝使用。

虽然清晰明确,但这并不是我们使用 Ownable 无法实现的。事实上,AccessControl 的亮点在于需要细粒度权限的场景,这可以通过定义多个角色来实现。
让我们通过定义一个'burner'角色来扩展ERC20 token,它使用onlyRole修饰符来允许账户销毁token:

solidity
// contracts/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor(address minter, address burner) ERC20("MyToken", "TKN") {
        _setupRole(MINTER_ROLE, minter);
        _setupRole(BURNER_ROLE, burner);
    }

    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) {
        _burn(from, amount);
    }
}

非常整洁!通过这样的拆分方式,我们可以实现比简单的所有权更多层级的访问控制。限制系统中每个组件能做的事情被称为最小特权原则,它是很好的安全策略。请注意,如果需要的话,同一个账户可以拥有多个不同的角色。

授予和撤销角色

在上面的ERC20示例中我们使用了_setupRole,这是一个很有用的internal函数,用来以编程的方式来分配角色(比如通过构造函数)。但是如果我们之后想给某个账户授权'minter'角色应该怎么操作呢。
默认情况下,拥有角色的账户不能被其他账户授权或撤销授权:拥有角色的判断就是通过hasRole检查。动态授权或撤销授权,需要管理员角色。
每个角色都有关联管理员角色,它通过调用grantRolerevokeRole函数操作权限。如果一个账户拥有管理员权限,就可以调用这些函数来授权或撤销。多个角色可以对应同一个管理员角色来简化管理。一个角色的管理员角色也可以是它本身,这就意味着该账户的角色也能够授权或撤销。
这套机制可以用来构建类似组织结构图一样的复杂权限系统,同时它也提供了一个简单方式来管理简单系统。AccessControl包含一个特殊的角色,叫DEFAULT_ADMIN_ROLE,它是所有角色的默认管理员。拥有这个角色的账户可以管理其他角色,除非手工调用_setRoleAdmin函数来指定一个新的管理员。
我们再来继续看一下ERC20的例子,这次我们利用默认管理员角色:

solidity
// contracts/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor() ERC20("MyToken", "TKN") {
        // Grant the contract deployer the default admin role: it will be able
        // to grant and revoke any roles
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) {
        _burn(from, amount);
    }
}

请注意,和之前的例子不同,没有账户被授予'minter'和'burner'角色。然而,这两个角色拥有同一个admin角色,它被授予给了msg.sender,这样这个账户就可以调用grantRole来授权挖矿或销毁操作,或者调用revokeRole来撤销授权。
动态的角色分配通常是合理的做法,比如在对参与者的信任随着时间变化的系统。它还可以使用在KYC这样的系统中,也就是角色担任者开始时并不知道,或者包含在同一个交易中,燃料费会非常高昂。

查询账户权限

由于账户可能会被动态的授权或撤销,因此我们不可能一直清楚的了解哪些账户有哪些角色权限。这一点非常重要,因为他牵涉了系统属性,比如管理员账户是使用多重签名还是DAO,或者某个角色从所有用户中删除了,从而可以有效的禁止相关功能的调用。
底层代码中,AccessControl使用EnumerableSet来实现关键字枚举,它是Solidity中一个非常强大的mapping变量类型。getRoleMemberCount可以返回某个角色中账户的数量,getRoleMember可以返回这些账户的地址。

js
const minterCount = await myToken.getRoleMemberCount(MINTER_ROLE);

const members = [];
for (let i = 0; i < minterCount; ++i) {
    members.push(await myToken.getRoleMember(MINTER_ROLE, i));
}

延迟操作

访问控制是防止未授权操作危害系统的保证。这些操作可能包含挖矿,冻结转账或执行一段彻底改变智能合约的升级逻辑。虽然OwnableAccessControl可以防止未授权的访问,但是它们并没有标记这些会危害其他用户的对管理员的攻击。
TimelockController解决了这个问题。
TimelockController是调用者和执行者之间的代理。当我们设置了智能合约的owner/admin/controller后,它确保了每个实际操作都会按照调用者的顺序执行,每个调用之间会有一个延迟。这个延迟保护了智能合约的用户,它们可以审查操作,并且在适当的时候退出系统。

使用TimelockController

默认情况下,部署TimelockController的账户地址获得时间锁的管理员权限。它可以给其他账户分配调用者,执行者或管理员角色。
第一步,在TimelockController中指定至少一个调用者和一个执行者。这些配置可以在构造函数里完成,或者部署后由具有管理员的用户来操作。这些角色不是互斥的,意味着一个账户可以即是调用者也可以是执行者。
这些角色是通过AccessControl接口来管理的,每个角色对应了ADMIN_ROLE,PROPOSER_ROLE,EXECUTOR_ROLE这些bytes32类型的数值常量。
有一个在AccessController上的附加特性:将执行者角色授权给address(0)就可以在时间锁过期后任何人都可以访问。这个特性很有用,但是使用时要非常小心。
在这里,如果调用者和执行者都已分配,那么时间锁就可以执行操作了。
下一步是可选的步骤,部署者可以放弃管理员权限,让时间锁自己来管理。如果部署者这样做了,进一步的维护,包括分配新的调用者和执行者,或者改变时间锁的持续周期将会遵循时间锁本身的流程。这将时间锁的管理和合约管理中的时间锁联系起来,并强制延迟时间锁的维护操作。

警告

如果部署者宣布放弃了管理员权限让时间锁自我管理,那么分配新的调用者和执行者将需要一个时间锁操作。这意味着如果这两个角色中的账户一旦变为不可用,那么整个合约(包括合约控制的其他合约)将会死锁。

包括调用者和执行者分配,时间锁的管理权限,你可以转移所有权/控制权给其他合约的时间锁。

提示

我们推荐的配置方式,是将角色授权等转移给一个安全的管理合约,比如DAO或多重签名,然后将调用者角色授权给一些EOAs,也就是那些帮助执行操作的账户。这些账户无法控制时间锁,但是可以让它的运行更加流畅。

最小延迟

TimelockController执行的操作不受固定延迟,而是最小延迟的影响。一些主要的升级操作可能会有一个较长时间的延迟。例如,如果审核挖矿操作需要几天的延迟,那么执行智能合约升级应该使用几周或几个月的延迟。
最小延迟(可以通过getMinDelay方法获得)可以通过updateDelay函数修改。请注意,只有时间锁本身才能访问此函数,这意味着这个维护操作必须通过时间锁本身。

Released under the MIT License.