标签 Go 下的文章

通过实例理解Web应用的机密管理

本文永久链接 – https://tonybai.com/2023/11/08/understand-go-web-secret-management-by-example

如果你是一个Web应用系统的开发人员,你的日常大概率是“乐此不疲”地做着CRUD的活儿,很少接触到安全方面的内容。如果这时有人和你提到“机密(信息)管理(secret management)”,你大概率会说:那是啥?和我有关系吗?

你只是大多应用系统开发人员中的一个典型代表。现阶段,很多开发人员,尤其是业务应用开发人员在工作中较少甚至没有接触过专门的机密管理系统,在系统设计时也较少考虑到机密管理方面的要求,精力仍主要集中在保证系统功能的正确性、性能等方面。这种对安全的重视程度不够,不了解机密管理的现象较为普遍,下面是一些常见的表现:

  • 明文存储密码、密钥等敏感数据

很多项目依然直接将用户密码、数据库连接密码、第三方服务密钥等信息明文写在代码或配置文件中,存在被攻击者直接获取的风险。

  • 硬编码密钥与密码

重复地在代码中多次硬编码密码、密钥等机密信息,导致不能统一变更及管理。

  • 使用弱密码、未定期更换

使用常见的弱密码,或使用默认或长期不变更的密码,很容易被猜测或破解。

  • 不同环境复用同一密钥

开发、测试、生产环境复用同一密钥,一旦泄露将影响所有环境。

  • 明文传输密码

HTTP传输中明文传递密码,导致可被嗅探截获。

  • 日志中输出明文密码

调试日志中直接输出数据库密码等敏感信息,可能被利用。

  • 缺乏访问控制和审计机制

密钥等资源无访问控制,且操作不被审计,难以追踪。

这些现象的普遍存在表明当前对于机密管理的重要性认知还有待提高,尤其是在当前互联网/移动互联网安全形势日益严峻的情况下,开发人员在系统开发的每个环节都应该意识到机密管理的重要性,并将机密管理纳入开发流程的各个阶段,这可以帮助大家构建出更可靠、安全的系统。

在这篇文章中,我就和大家一起来了解一下什么是机密管理,日常进行Web应用开发过程中该如何集成机密管理来保证机密信息在存储、传输、使用过程中的安全,最后,通过实例的方式来剖析Web应用是如何对一些典型的机密信息进行机密管理的。

1. 认识机密管理

在IT领域,机密管理是一种网络安全最佳实践,用于持续地、自动化地管理和保护数字身份验证凭证(如密码、密钥、API令牌等机密信息),确保这些机密信息只能被经过授权的实体在严格的访问控制下使用。

机密管理拥有一套自己的核心管理措施,包括:

  • 从代码、配置文件和其他未经受保护的区域中删除明文机密信息,将机密信息与代码/配置隔离存储;
  • 执行最小特权(Least Privilege)原则,即设计访问控制时,用户和程序只会被授予执行其任务所需的最小/最低权限;
  • 执行严格的访问控制(尤其是要对所有非人类凭证的访问请求进行验证),并对所有访问进行跟踪和全面审计;
  • 定期对机密信息(secrets)和凭证(credentials)进行轮转(rotate);
  • 自动管理机密信息的全生命周期,例如存储、分发、轮转等,并应用一致的访问策略;
  • … …

机密管理涉及要管理的机密信息的类型包括(但不限于):

  • 用户密码或自动生成的密码
  • API和其他应用程序的密钥(Key)/凭证(包括容器内的密钥/凭证)
  • SSH密钥
  • 数据库和其他system-to-system的密码
  • 用于安全通信、传输和接收数据的私人证书(TLS、SSL 等)
  • RSA和其他一次性密码设备

综合上面信息,我们看到机密管理不仅有一套严格的管理措施,而且要管理的机密信息的类型也是很多,并且随着软件系统复杂性的增加,云原生应用兴起,需要管理的机密类型和数量激增,不仅包括传统的密码和密钥,还有云平台的访问证书、微服务间的通信令牌等;管理难度也会大大提高。远程访问和云部署使得传统的边界安全防护变得困难。机密信息传输和存储的渠道更多,风险也上升。高速迭代的软件交付流程和自动化部署,也要求机密管理能同步地快速响应和自动化,机密管理面临着越来越大的挑战。面对这些挑战,业界迫切需要引入自动化、智能化和专业化的机密管理系统来应对。

2. 机密管理系统

机密管理系统是一套专业的用于集中存储、管理、控制和监控机密信息的安全解决方案。机密管理系统的发展经历了一个从分散到集中、从静态到动态、从本地到云端、从加密到访问控制、从人工操作到DevOps自动集成的发展历程,这个历程大致可分为如下几个阶段:

  • 文件加密阶段

早期开发人员通过对文档和配置文件进行加密来保护机密信息,代表技术是PGP等加密软件。但很显然,这种方式操作不便,不支持访问控制等高级功能。

  • 自建解决方案阶段

企业开始自研一些机密管理解决方案(包括基于一些像KeePass这样的开源项目),但功能有限,更多是局限于满足企业自己的需求,很少支持跨平台和集中管理等功能。

  • 开源机密管理项目

随着云计算时代的到来,开源社区推出了支持云和容器的自动化机密管理项目,例如:VaultKeywhiz等,这些项目的一些公同的功能特性包括:轻量化实现、支持访问控制、提供机密信息版本控制、提供审计功能、提供API便于应用集成、支持与 CI/CD 工具集成、支持Docker、Kubernetes等容器平台等。这一时期的开源机密管理系统大大简化了机密管理流程,为随后的云原生机密管理平台的发展奠定了基础。

注:Keywhiz目前2023年9月宣布不再开发,建议使用Hashicorp Vault。

  • 云原生机密管理平台

在开源机密管理项目的基础之上,这些开源项目背后的开发商以及一些专业的公有云提供商开始面向云原生应用和DevOps,以SaaS形式提供专业的机密管理服务和全面的机密管理解决方案,如Azure Key VaultGoogle Secret ManagerAWS Secrets Manager、HashiCorp Vault等。

我们看到:专业的机密系统发展到今天的水平,其过程不是一蹴而就的。正是基于历史经验的积累和总结,现代机密管理平台才演化出了面向云原生架构、支持DevOps、细粒度访问控制、机密信息的动态化以及生命周期的自动化管理等先进功能特性。

在上面的优秀的云原生机密管理系统中,HashiCorp Vault是唯一开源且可以私有化部署在企业内部的。HashiCorp公司于2015年发布并开源了Vault,经过多年发展,Vault已经发展成为一款功能强大的企业级机密管理系统,并被广泛视为云原生领域的首选解决方案。

对于普通Web应用开发者而言,既要有机密管理的意识,又要有机密管理的实现手段。HashiCorp Vault的设计目标之一就是将机密管理下沉到平台层面,让应用开发者能够专注于应用程序的开发而无需过多关注机密的管理和保护。

作为Web应用开发者,基于Vault实现Web应用的机密管理是一条非常可行的机密管理方案。通过与Vault的集成,Web应用开发者可以利用Vault提供的丰富功能来处理各种机密管理需求和场景。开发者只需要学习如何使用Vault的API或客户端库与Vault进行交互,就能轻松地访问和管理机密数据,实现机密信息(如数据库凭据、API 密钥等)获取、动态机密信息生成、访问控制、审计和监控等机密管理功能,并且可以减少机密管理的开发和维护的复杂性。

接下来,我就和大家一起简要的了解一下Hashicorp的Vault。

3. 认识Vault

3.1 Vault的架构

如果对Hashicorp这家公司很熟悉,你肯定知道Hashicorp大部分产品(和开源项目)都是由Go开发的,包括consulnomadterraform以及vagrant(vagrant的新版本将切换到go实现)等。

Vault这款优秀的机密管理软件系统继承了Hashicorp的开发基因,也是由Go语言开发的。从2015年至今,Vault已经演化为一个功能强大,但相对也比较复杂的系统,下面是Hashicorp官方架构文档中的一个关于Vault的high level的结构示意图:

从整体架构设计思路来看,vault支持:

  • 高可用性

Vault的架构设计允许部署多个Vault服务器以实现高可用性和容错性,在高可用集群部署模式下,多个vault服务器共享存储后端,并且每个vault服务器可能是两个状态:active和standby。任意时刻集群都只有一个实例处于active状态,所有standby实例都处于热备用状态(hot standby)。只有处于active状态的服务器会处理所有请求;standby服务器会将所有请求重定向到活动Vault服务器,这点与consul的设计是一致的。如果active服务器被sealed、发生故障或失去网络连接,则standby Vault服务器中的一个将成为active实例。

这里有人可能会问:如果只有一个active实例,那么在访问量增大的时候,active实例便会成为热点或性能瓶颈!没错,这是vault开源版本的约束。这个约束在vault的企业付费版中被取消,在付费版中,standby服务器可以接收只读请求,所有只读请求会均衡分担到各个standby实例上,如果standby实例收到写请求,它会将写请求转发给active实例处理。

  • 封存和解封

说高可用性时,我们提到了vault服务器实例的sealed(封存)状态。启动Vault服务器时,它会处于sealed状态。在这种状态下,Vault仅知道访问物理存储的位置和方式,但不知道如何解密存储中数据。在unseal(解封)之前,该vault服务器几乎无法做任何操作。在对处于sealed状态的Vault实例进行任何操作之前,必须对其进行解封(unseal)。

解封操作需要提供解封密钥(unseal keys)。有人注意到了,我用了unseal keys,而不是unseal key,因为解封密钥是由一种名为Shamir’s Secret Sharing的算法分解保存和汇集生成的。Shamir’s Secret Sharing(Shamir的机密分享算法)是一种密码学算法,用于将机密数据(在本文中指的就是“unseal key”)分割成多个部分,称为shares。这些share可以被分发给不同的人,如下图所示:

而只有当足够数量的share被汇集时,才能恢复出原始的机密数据(unseal key),并用恢复出的机密数据进行下一步操作(如下图所示,下图来自Hashicorp官方文档):

在这幅图中,当汇集一定个数的unseal keys’share后,vault就能够重构解封密钥(“unseal key”),然后用它来解密得到根密钥(root key,也称为master key),根密钥再被用来解密得到加密密钥(Encryption key)用于保护所有vault的数据,即这个Encryption key就是后续参与机密数据加解密的密钥。

注:实际生产部署时,究竟要如何对Vault Server进行unseal,HashiCorp提供了一些unseal pattern供大家参考。

  • 加密层

前面架构图中左侧南北横贯多层的部分是Vault的加密层,被称为barrier,负责对Vault数据进行加密和解密,确保数据在存储和传输过程中的机密性和完整性。Vault服务器启动时,会将数据写入存储后端。由于存储后端位于barrier之外,被视为不可信的(与零信任网络理念一致),因此Vault会在将数据发送到存储后端之前对其进行加密。这种机制确保了即便恶意攻击者试获取了对存储后端的访问权限,其拿到的数据仍然保持加密状态。

  • 认证和授权

如下图(来自Hashicorp官方文档),当客户端首次连接到Vault时,需要进行身份验证。Vault提供可配置的认证方法,并在身份验证机制上提供灵活性。操作员可以使用用户名/密码等机制进行身份验证,而应用程序可以使用公钥/私钥或令牌进行身份验证。 Core(核心)负责管理请求的流程,包括流经哪个身份验证方法来确定请求是否有效,并得到关联策略的列表,执行访问控制规则(ACLs),确保审计日志记录,并将请求路由到相应的机密引擎进行处理。

  • 策略管理

策略是一组命名的访问控制规则。Vault内置了一些策略,如”root”策略,允许对所有资源的访问。用户可以创建任意数量的命名策略,并对路径进行细粒度的控制。除非通过策略明确授权,否则不允许进行操作。

  • 机密引擎

Vault使用机密引擎来生成和管理动态机密数据,如临时凭据、API密钥等。机密引擎的类型可以是静态的,如数据库凭据,也可以是动态的,如 AWS IAM凭据。机密引擎根据配置的规则和策略生成和提供机密数据。

  • 审计和日志记录

Vault记录请求和响应的审计日志,并有Audit Broker(审计代理)将其分发到配置的审计设备(audit device)。审计日志用于监控和审计对Vault的访问和操作。

  • Expiration Manager(租期管理)

Vault由Expiration Mgr管理令牌和机密数据的过期,自动回收已过期的客户端令牌和机密数据。

  • Token Store(令牌存储)

Token Store生成和管理客户端令牌,用于进行后续的请求操作。令牌类似于网站登录时发送的 cookie,用于验证客户端的身份和授权。

以上是Vault的主要架构设计思路和各部分的功能范围。Vault的架构保证了安全性、高可用性和可扩展性,使用户能够安全地管理和保护机密信息。

3.2 Vault的安全模型

Vault是做机密信息管理的,其自身安全模型是否完善直接关系到应用系统的安全。Vault官方也十分重视这点,在官方文档中也对其安全模型做了说明,这里梳理一下。

Vault的安全模型旨在提供数据的机密性、完整性、可用性、可追溯性和认证性。以下是Vault安全模型的几个设计要点:

  • 通信安全

Vault要求客户端与服务器之间的通信通过TLS建立安全通道,以确保通信的机密性和完整性。此外,Vault服务器之间的集群通信也使用相互认证的TLS,以保护数据在传输过程中的机密性和完整性。

  • 身份验证和授权

前面说架构时提及过:所有客户端请求必须经过适当的身份验证和授权。当客户端首次进行身份验证时,Vault使用认证方法验证客户端的身份,并返回与其关联的ACL策略列表。每个请求都需要提供有效的客户端令牌,Vault根据令牌验证其有效性,并生成基于关联策略的访问控制列表(ACL)。

  • 数据安全

Vault对于存储在后端的数据,以及在传输过程中的数据,都要求保证安全。Vault使用256位的高级加密标准(AES)密码和96位随机数作为加密密钥,对离开Vault的所有数据进行加密。同时,在解密过程中验证Galios Counter Mode(GCM)的认证标签,以检测任何篡改。

  • 内部威胁保护

Vault关注内部攻击威胁,即已经获得某种程度Vault访问权限的攻击者企图获取未经授权的机密信息。Vault在客户端进行身份验证时,使用事先配置的关联策略列表来生成客户端令牌,并使用严格的默认拒绝策略来进行访问控制。每个策略指定对Vault中路径的访问级别,最终的访问权限由所有关联策略中最高级别的权限决定。

  • 密钥管理

Vault使用Shamir’s Secret Sharing技术来实现密钥的管理和保护unseal key,本质上也是对Root key和Encryption key的保护。只有在提供足够数量的share时,才能恢复unseal密钥,这样可以避免对单个持有者的绝对信任,同时也不需要存储完整的加密密钥。

但需要注意的是,Vault的安全模型并不涵盖所有可能的威胁和攻击,例如对存储后端的完全控制、存储后端中存在的秘密信息的泄露、运行中的Vault实例内存分析等。此外,Vault还依赖于外部系统或服务的安全性,如果这些外部系统存在漏洞或受到攻击,可能会导致Vault中数据的机密性或完整性受到威胁。

说了这么多Vault,Vault究竟长什么样?应该如何用呢?接下来我们简单介绍一下Vault的安装和使用,也是为后续的实例部分做个铺垫。

3.3 Vault的安装

Vault支持多种形式的安装部署,包括基于预编译好的二进制文件(precompiled binary)、基于容器或包管理器等,你甚至可以自己基于源码编译。

我这里使用的是Precompiled binary方式,将Vault直接部署在我的开发环境下,一台MacBook Pro上。

Precompiled binary下载后就是一个可执行文件,把它放到特定路径下,并在PATH环境变量中将这个路径加入进来,环境变量生效后,你就可以在任意路径下使用vault命令了。

下面的命令打印了下载的vault的版本:

$vault -v
Vault v1.15.1 (b94e275f25ccd9011146d14c00ea9e49fd5032dc), built 2023-10-20T19:16:11Z

通过-h命令行参数,可以查看vault的命令帮助信息:

$vault -h
Usage: vault <command> [args]

Common commands:
    read        Read data and retrieves secrets
    write       Write data, configuration, and secrets
    delete      Delete secrets and configuration
    list        List data or secrets
    login       Authenticate locally
    agent       Start a Vault agent
    server      Start a Vault server
    status      Print seal and HA status
    unwrap      Unwrap a wrapped secret

Other commands:
    audit                Interact with audit devices
    auth                 Interact with auth methods
    debug                Runs the debug command
    events
    kv                   Interact with Vault's Key-Value storage
    lease                Interact with leases
    monitor              Stream log messages from a Vault server
    namespace            Interact with namespaces
    operator             Perform operator-specific tasks
    patch                Patch data, configuration, and secrets
    path-help            Retrieve API help for paths
    pki                  Interact with Vault's PKI Secrets Engine
    plugin               Interact with Vault plugins and catalog
    policy               Interact with policies
    print                Prints runtime configurations
    proxy                Start a Vault Proxy
    secrets              Interact with secrets engines
    ssh                  Initiate an SSH session
    token                Interact with tokens
    transform            Interact with Vault's Transform Secrets Engine
    transit              Interact with Vault's Transit Secrets Engine
    version-history      Prints the version history of the target Vault server

注:Vault继承了Hashicorp产品的一贯风格,即将所有功能放到一个程序中,各个功能通过subcommand的形式提供,比如vault server、vault agent、vault proxy等。如果你了解consul,你会发现consul就是这样的。

3.4 Vault的启动(dev模式)

生产环境的Vault部署、配置、启动以及unseal过程还是蛮复杂的,HashiCorp给了一些参考集群架构,这些可以交给运维同学去琢磨。

对于开发人员而言,日常将应用与Vault集成实现机密管理的时候,只需在本机或远程开发机上启动dev模式的Vault实例即可,这里我们也基于dev模式来启动一个单实例的Vault:

$vault server -dev
==> Vault server configuration:

Administrative Namespace:
             Api Address: http://127.0.0.1:8200
                     Cgo: disabled
         Cluster Address: https://127.0.0.1:8201
   Environment Variables: Apple_PubSub_Socket_Render, CLASSPATH, CLISH_PATH, ETCDCTL_API, GITEA_WORK_DIR, GODEBUG, GONOPROXY, GONOSUMDB, GOPATH, GOPRIVATE, GOPROXY, GOROOT, GOSUMDB, HOME, HOMEBREW_BOTTLE_DOMAIN, LANG, LC_CTYPE, LESS, LOGNAME, LSCOLORS, MML_HOME, NVM_BIN, NVM_CD_FLAGS, NVM_DIR, OLDPWD, OPENCV_PATH, PAGER, PATH, PWD, PYTHONPATH, RUSTUP_DIST_SERVER, RUSTUP_UPDATE_ROOT, SHELL, SHLVL, SSH_AUTH_SOCK, TERM, TERM_PROGRAM, TERM_PROGRAM_VERSION, TERM_SESSION_ID, TMPDIR, USER, XPC_FLAGS, XPC_SERVICE_NAME, ZSH, _
              Go Version: go1.21.3
              Listener 1: tcp (addr: "127.0.0.1:8200", cluster address: "127.0.0.1:8201", max_request_duration: "1m30s", max_request_size: "33554432", tls: "disabled")
               Log Level:
                   Mlock: supported: false, enabled: false
           Recovery Mode: false
                 Storage: inmem
                 Version: Vault v1.15.1, built 2023-10-20T19:16:11Z
             Version Sha: b94e275f25ccd9011146d14c00ea9e49fd5032dc

==> Vault server started! Log data will stream in below:

2023-11-06T10:25:37.723+0800 [INFO]  proxy environment: http_proxy="" https_proxy="" no_proxy=""
2023-11-06T10:25:37.727+0800 [INFO]  incrementing seal generation: generation=1
2023-11-06T10:25:37.727+0800 [WARN]  no `api_addr` value specified in config or in VAULT_API_ADDR; falling back to detection if possible, but this value should be manually set
2023-11-06T10:25:37.733+0800 [INFO]  core: Initializing version history cache for core
2023-11-06T10:25:37.734+0800 [INFO]  events: Starting event system
2023-11-06T10:25:37.736+0800 [INFO]  core: security barrier not initialized
2023-11-06T10:25:37.737+0800 [INFO]  core: security barrier initialized: stored=1 shares=1 threshold=1
2023-11-06T10:25:37.744+0800 [INFO]  core: post-unseal setup starting
2023-11-06T10:25:37.758+0800 [INFO]  core: loaded wrapping token key
2023-11-06T10:25:37.758+0800 [INFO]  core: successfully setup plugin runtime catalog
2023-11-06T10:25:37.758+0800 [INFO]  core: successfully setup plugin catalog: plugin-directory=""
2023-11-06T10:25:37.760+0800 [INFO]  core: no mounts; adding default mount table
2023-11-06T10:25:37.765+0800 [INFO]  core: successfully mounted: type=cubbyhole version="v1.15.1+builtin.vault" path=cubbyhole/ namespace="ID: root. Path: "
2023-11-06T10:25:37.774+0800 [INFO]  core: successfully mounted: type=system version="v1.15.1+builtin.vault" path=sys/ namespace="ID: root. Path: "
2023-11-06T10:25:37.777+0800 [INFO]  core: successfully mounted: type=identity version="v1.15.1+builtin.vault" path=identity/ namespace="ID: root. Path: "
2023-11-06T10:25:37.783+0800 [INFO]  core: successfully mounted: type=token version="v1.15.1+builtin.vault" path=token/ namespace="ID: root. Path: "
2023-11-06T10:25:37.785+0800 [INFO]  rollback: Starting the rollback manager with 256 workers
2023-11-06T10:25:37.787+0800 [INFO]  rollback: starting rollback manager
2023-11-06T10:25:37.789+0800 [INFO]  core: restoring leases
2023-11-06T10:25:37.791+0800 [INFO]  identity: entities restored
2023-11-06T10:25:37.791+0800 [INFO]  identity: groups restored
2023-11-06T10:25:37.791+0800 [INFO]  expiration: lease restore complete
2023-11-06T10:25:37.793+0800 [INFO]  core: Recorded vault version: vault version=1.15.1 upgrade time="2023-11-06 02:25:37.793171 +0000 UTC" build date=2023-10-20T19:16:11Z
2023-11-06T22:25:38.367+0800 [INFO]  core: post-unseal setup complete
2023-11-06T22:25:38.368+0800 [INFO]  core: root token generated
2023-11-06T22:25:38.368+0800 [INFO]  core: pre-seal teardown starting
2023-11-06T22:25:38.369+0800 [INFO]  rollback: stopping rollback manager
2023-11-06T22:25:38.369+0800 [INFO]  core: pre-seal teardown complete
2023-11-06T22:25:38.370+0800 [INFO]  core.cluster-listener.tcp: starting listener: listener_address=127.0.0.1:8201
2023-11-06T22:25:38.370+0800 [INFO]  core.cluster-listener: serving cluster requests: cluster_listen_address=127.0.0.1:8201
2023-11-06T22:25:38.371+0800 [INFO]  core: post-unseal setup starting
2023-11-06T22:25:38.371+0800 [INFO]  core: loaded wrapping token key
2023-11-06T22:25:38.371+0800 [INFO]  core: successfully setup plugin runtime catalog
2023-11-06T22:25:38.371+0800 [INFO]  core: successfully setup plugin catalog: plugin-directory=""
2023-11-06T22:25:38.372+0800 [INFO]  core: successfully mounted: type=system version="v1.15.1+builtin.vault" path=sys/ namespace="ID: root. Path: "
2023-11-06T22:25:38.372+0800 [INFO]  core: successfully mounted: type=identity version="v1.15.1+builtin.vault" path=identity/ namespace="ID: root. Path: "
2023-11-06T22:25:38.372+0800 [INFO]  core: successfully mounted: type=cubbyhole version="v1.15.1+builtin.vault" path=cubbyhole/ namespace="ID: root. Path: "
2023-11-06T22:25:38.373+0800 [INFO]  core: successfully mounted: type=token version="v1.15.1+builtin.vault" path=token/ namespace="ID: root. Path: "
2023-11-06T22:25:38.373+0800 [INFO]  rollback: Starting the rollback manager with 256 workers
2023-11-06T22:25:38.373+0800 [INFO]  rollback: starting rollback manager
2023-11-06T22:25:38.374+0800 [INFO]  core: restoring leases
2023-11-06T22:25:38.374+0800 [INFO]  expiration: lease restore complete
2023-11-06T22:25:38.374+0800 [INFO]  identity: entities restored
2023-11-06T22:25:38.374+0800 [INFO]  identity: groups restored
2023-11-06T22:25:38.374+0800 [INFO]  core: post-unseal setup complete
2023-11-06T22:25:38.374+0800 [INFO]  core: vault is unsealed
2023-11-06T22:25:38.386+0800 [INFO]  core: successful mount: namespace="" path=secret/ type=kv version=""
WARNING! dev mode is enabled! In this mode, Vault runs entirely in-memory
and starts unsealed with a single unseal key. The root token is already
authenticated to the CLI, so you can immediately begin using Vault.

You may need to set the following environment variables:

    $ export VAULT_ADDR='http://127.0.0.1:8200'

The unseal key and root token are displayed below in case you want to
seal/unseal the Vault or re-authenticate.

Unseal Key: KiF1ohtchsOjr4IvzHY38/OAPOqS1/rARczTFG6Ull8=
Root Token: hvs.9QOJsa7zlwHO8ieW15CXXoOp

Development mode should NOT be used in production installations!

我们看到dev模式下,Vault server是自动unseal的,并打印出了Unseal Key和Root Token,而且显式地告诉你:所有机密数据都是存储在内存中的,不要将这个模式用于生产环境

前面说过,vault程序继承了Hashicorp产品的基因,它既可以用来启动server,其自身也是一个命令行程序,我们可以用vault命令查看启动的server的状态:

$vault status
Error checking seal status: Get "https://127.0.0.1:8200/v1/sys/seal-status": http: server gave HTTP response to HTTPS client

我们看到:获取vault server状态的命令执行失败,因为我们并没有开启vault server的https端口,仅使用了http端口。我们设置一下环境变量后,再执行status命令:

$export VAULT_ADDR='http://127.0.0.1:8200' // 设置vault server addr为http非安全方式
$vault status
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         1.15.1
Build Date      2023-10-20T19:16:11Z
Storage Type    inmem
Cluster Name    vault-cluster-23f54192
Cluster ID      a86c14e2-b88c-5391-e8b4-0b1b9e9a9aaf
HA Enabled      false

接下来,我们试着向Vault写入一个机密信息。Vault支持多种secret engine,比如:Key/Value secrets engineVersioned Key/value secrets engine(k/v引擎的v2版本)LDAP secrets engineAzure secrets engine等,其中K/V引擎以及带版本的K/V引擎是最常用的。

注:Vault还支持开发者自定义secret engine

我们尝试使用kv子命令向vault中写入一个key/value,放到secret路径下(在dev模式下,secret路径下自动开启v2版本引擎),key为hello,值为foo=world:

$vault kv put -mount=secret hello foo=world
Error making API request.

URL: GET http://127.0.0.1:8200/v1/sys/internal/ui/mounts/secret
Code: 403. Errors:

* permission denied

我们看到命令执行失败,提示没有权限。vault server要求每个访问请求都必须带上token,我们可以使用vault server启动时打印的root token,可以使用环境变量的方式将token注入:

export VAULT_TOKEN="hvs.9QOJsa7zlwHO8ieW15CXXoOp"

也可以执行下面命令并输入root token完成登录:

$vault login
Token (will be hidden):
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                hvs.9QOJsa7zlwHO8ieW15CXXoOp
token_accessor       170OHOscEZjfl8fSa8aVpNkZ
token_duration       ∞
token_renewable      false
token_policies       ["root"]
identity_policies    []
policies             ["root"]

之后,root token就被放置在“~/.vault-token”中了:

$cat ~/.vault-token
hvs.9QOJsa7zlwHO8ieW15CXXoOp

注:我们通常不会使用root token,而是会利用vault token命令生成新token作为vault cli访问vault server的token。

现在我们重新执行一下kv put命令:

$vault kv put -mount=secret hello foo=world
== Secret Path ==
secret/data/hello

======= Metadata =======
Key                Value
---                -----
created_time       2023-11-06T03:01:25.968883Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            2

kv创建成功,路径secret/data/hello(注:vault会默认在mount的路径secret下创建data路径)。vault server在将value值存储在backend storage(这里是memory)前,会用Encryption Key对内容进行加密。如果你多执行几次这个命令,你会发现输出信息中的version的数值会递增,这个数值表示设置的值的版本。

我们可以用kv get获取刚才写入的kv值,vault会将数据从backend storage中读取出来并解密:

$vault kv get -mount=secret hello
== Secret Path ==
secret/data/hello

======= Metadata =======
Key                Value
---                -----
created_time       2023-11-06T03:01:25.968883Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            2

=== Data ===
Key    Value
---    -----
foo    world

我们还可以通过delete删除刚刚建立的kv值(为后面的基本场景示例做铺垫):

$vault kv delete secret/foo
Success! Data deleted (if it existed) at: secret/data/foo
$vault kv get secret/foo
No value found at secret/data/foo

到这里我们看到,一旦vault安装完毕后,基本使用场景还是蛮简单的,但也仅限于基本使用场景^_^。下面我们再来看看如何通过代码来实现这些基本功能场景。

3.5 使用client SDK与Vault交互

Vault支持各种主流语言的client SDK,其中Vault官方维护了三个:Go、Ruby和C#,其他语言的SDK则是由社区维护。

我们用Go Client SDK来编写一个设置kv和获取kv值的小程序,如下面代码所示:

// secret-management-examples/basic/main.go

package main

import (
    "context"
    "fmt"

    "github.com/hashicorp/vault/api"
)

func main() {
    // 创建一个新的Vault客户端
    client, err := api.NewClient(api.DefaultConfig())
    if err != nil {
        fmt.Println("无法创建Vault客户端:", err)
        return
    }

    // 设置Vault服务器的地址
    client.SetAddress("http://localhost:8200/")

    // 设置Vault的访问令牌(如果需要认证)
    client.SetToken("hvs.9QOJsa7zlwHO8ieW15CXXoOp")

    // 设置要写入的机密信息
    secretData := map[string]interface{}{
        "foo": "bar",
    }

    kv2 := client.KVv2("secret") // mount "secret"

    // 写入机密信息到Vault的secret/data/{key}路径下
    key := "hello"
    _, err = kv2.Put(context.Background(), key, secretData)
    if err != nil {
        fmt.Println("无法写入机密信息:", err)
        return
    }

    // 读取Vault的secret/data/{key}路径下的机密信息
    secret, err := kv2.Get(context.Background(), key)
    if err != nil {
        fmt.Println("无法读取机密信息:", err)
        return
    }

    // 打印读取到的值
    fmt.Println("读取到的值:", secret.Data)
}

我们看到:默认创建的api.Client操作的都是v1版本的数据,这里通过KVv2方法将其转换为可以操作v2版本数据的client,之后put和get就可以如预期正常工作了!

下面是其运行结果:

$go run main.go
读取到的值: map[foo:bar]

有了基础场景做铺垫,接下来我们就进入实例环节,看看应用是如何基于Vault应对一些常见的机密管理场景的。

4. 常见的机密管理场景

Vault支持对多种机密信息的管理,包括应用访问外部服务或资源所需的用户名/密码、API密钥或访问令牌(token),应用程序的配置中的机密配置信息,比如数据库连接字符串、加密密钥等,以及私钥、证书等加密相关的机密信息等。这里我们就分别来看看应用与Vault集成并获取这些机密信息的场景,不过在这之前,我们首先需要先来了解一下应用本身与Vault是如何集成的。

4.1 应用通过Vault身份认证和授权的方法

在3.5小节的基本场景示例中,我们的client使用了一个长期有效的token通过了Vault的身份认证和授权环节,拥有了操作Vault数据的权限。

token auth方法也是dev模式下Vault server实例支持的唯一auth method,我们可以通过auth list命令查看vault server当前支持的auth方法集合:

$vault auth list
Path      Type     Accessor               Description                Version
----      ----     --------               -----------                -------
token/    token    auth_token_6f9cc41c    token based credentials    n/a

不过,基于token来实现app与Vault的集成并非Vault官方推荐的在生产环境使用的auth方式,理由也很明显:这种方式涉及手动创建一个长期有效的令牌,这有悖于最佳实践,并存在安全风险。

除了Token auth method,Vault还支持AppRoleJWT/OIDCTLS证书以及User/Password多种auth method,这些auth method的共同之处在于通过身份认证后,Vault可自动创建短期令牌供客户端使用,无需定期手动生成新令牌,短期令牌可以减少令牌泄露的风险,因为短期令牌在一定时间后会自动失效,并需要重新进行身份认证。

简单起见,我这里就用User/Password method作为实例演示一下应用通过Vault的身份认证和授权。

我们先来开启(enable)基于User/Password的auth method:

$vault auth enable userpass
Success! Enabled userpass auth method at: userpass/

该命令默认将会启用auth/userpass路径,之后通过auth list查看,就能在list中看到新增的userpass auth method了:

$vault auth list
Path         Type        Accessor                  Description                Version
----         ----        --------                  -----------                -------
token/       token       auth_token_6f9cc41c       token based credentials    n/a
userpass/    userpass    auth_userpass_b5b6e974    n/a                        n/a

接下来,我们在vault服务实例中建立一个新的user:

$vault write auth/userpass/users/tonybai password=ilovegolang
Success! Data written to: auth/userpass/users/tonybai

$vault read auth/userpass/users/tonybai
Key                        Value
---                        -----
token_bound_cidrs          []
token_explicit_max_ttl     0s
token_max_ttl              0s
token_no_default_policy    false
token_num_uses             0
token_period               0s
token_policies             [default]
token_ttl                  0s
token_type                 default

下面是示例代码:

// secret-management-examples/auth_user_password/main.go

package main

import (
    "context"
    "fmt"

    "github.com/hashicorp/vault/api"
    auth "github.com/hashicorp/vault/api/auth/userpass"
)

func main() {
    user := "tonybai"
    pass := "ilovegolang"

    // 创建Vault API客户端
    client, err := api.NewClient(api.DefaultConfig())
    if err != nil {
        fmt.Printf("无法创建Vault客户端: %v\n", err)
        return
    }
    // 设置 Vault 地址
    client.SetAddress("http://localhost:8200")

    // client登录vault服务器获取临时访问令牌
    userpassAuth, err := auth.NewUserpassAuth(user, &auth.Password{FromString: pass})
    if err != nil {
        fmt.Errorf("无法初始化userpass auth method: %w", err)
        return
    }

    secret, err := client.Auth().Login(context.Background(), userpassAuth)
    if err != nil {
        fmt.Errorf("登录Vault失败: %w", err)
        return
    }
    if secret == nil {
        fmt.Printf("登录后没有secret信息返回: %v\n", err)
        return
    }
    fmt.Printf("登录Vault成功\n")

    token := secret.Auth.ClientToken

    // 设置临时访问令牌
    client.SetToken(token)

    kv2 := client.KVv2("secret") // mount "secret"
    // 读取Vault的secret/data/{key}路径下的机密信息
    data, err := kv2.Get(context.Background(), "hello")
    if err != nil {
        fmt.Println("无法读取机密信息:", err)
        return
    }

    // 打印读取到的值
    fmt.Println("读取到的值:", data.Data)
}

如果你在Vault的GO SDK中没有找到对user/password auth method的直接支持,你也可以参考user/password auth method的API文档自行实现登录Vault并读取特定机密信息,代码如下(与上面代码功能是等价的):

// secret-management-examples/auth_user_password_self_impl/main.go

func clientAuth(vaultAddr, user, pass string) (*api.Secret, error) {
    payload := fmt.Sprintf(`{"password": "%s"}`, pass)

    req, err := http.NewRequest("POST", vaultAddr+"/v1/auth/userpass/login/"+user, strings.NewReader(payload))
    if err != nil {
        return nil, err
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

    if resp.StatusCode != http.StatusOK {
        return nil, errors.New(string(body))
    }

    return api.ParseSecret(bytes.NewReader(body))
}

func main() {
    vaultAddr := "http://localhost:8200"
    user := "tonybai"
    pass := "ilovegolang"

    // client登录vault服务器获取临时访问令牌
    secret, err := clientAuth(vaultAddr, user, pass)
    if err != nil {
        fmt.Printf("登录Vault失败: %v\n", err)
        return
    }
    fmt.Printf("登录Vault成功\n")

    // 创建Vault API客户端
    client, err := api.NewClient(api.DefaultConfig())
    if err != nil {
        fmt.Printf("无法创建Vault客户端: %v\n", err)
        return
    }

    // 设置 Vault 地址
    client.SetAddress("http://localhost:8200")
    token := secret.Auth.ClientToken

    // 设置临时访问令牌
    client.SetToken(token)

    kv2 := client.KVv2("secret") // mount "secret"
    // 读取Vault的secret/data/{key}路径下的机密信息
    data, err := kv2.Get(context.Background(), "hello")
    if err != nil {
        fmt.Println("无法读取机密信息:", err)
        return
    }

    // 打印读取到的值
    fmt.Println("读取到的值:", data.Data)
}

我们运行一下上述两个示例代码之一:

$go run main.go
登录Vault成功
无法读取机密信息: error encountered while reading secret at secret/data/hello: Error making API request.

URL: GET http://localhost:8200/v1/secret/data/hello
Code: 403. Errors:

* 1 error occurred:
    * permission denied

通过错误信息来看,“tonybai”这个user没有权限读取secret/data/hello下的机密信息!那么怎么给这个用户加上secret/data/hello的读取权限呢?Vault通过policy来管理权限,如果某个user具有某个policy的绑定,那么该user就拥有该policy设定的权限,这有点像RBAC的思路,只是没有引入role的概念! 我们先来添加一个拥有secret/data/hello读权限的policy:

$vault policy write my-policy -<<EOF
# Allow "read" permission on "secret/data/*" secrets
path "secret/data/*" {
  capabilities = ["read"]
}
EOF
Success! Uploaded policy: my-policy

接下来重写user的属性数据,将my-policy赋给”tonybai”这个user:

$vault write auth/userpass/users/tonybai password=ilovegolang token_policies=my-policy
Success! Data written to: auth/userpass/users/tonybai

$vault read auth/userpass/users/tonybai
Key                        Value
---                        -----
token_bound_cidrs          []
token_explicit_max_ttl     0s
token_max_ttl              0s
token_no_default_policy    false
token_num_uses             0
token_period               0s
token_policies             [my-policy]
token_ttl                  0s
token_type                 default

完成上述设置后,我们再来运行一下基于user/password auth method的程序:

$go run main.go
登录Vault成功
读取到的值: map[foo:bar]

这次程序成功登录Vault并成功读取了secret/data/hello下面的机密数据。

这里我们除了设置了token_policies,其他属性都保持了默认值,这样我们拿到的临时token其实并不“临时”,我们可以一直使用。下面我们通过设置token_ttl来指定每个临时token的最大有效时间:

$vault write auth/userpass/users/tonybai password=ilovegolang token_policies=my-policy token_ttl=5s
Success! Data written to: auth/userpass/users/tonybai

$vault read auth/userpass/users/tonybai
Key                        Value
---                        -----
token_bound_cidrs          []
token_explicit_max_ttl     0s
token_max_ttl              0s
token_no_default_policy    false
token_num_uses             0
token_period               0s
token_policies             [my-policy]
token_ttl                  5s
token_type                 default

我们改写一下程序,让程序每隔1秒用临时token获取一下机密信息并输出:

// secret-management-examples/auth_user_password_renewal/main.go (临时版本)

    for {
        // 每个一秒读取一次Vault的secret/data/{key}路径下的机密信息
        data, err := kv2.Get(context.Background(), "hello")
        if err != nil {
            fmt.Println("无法读取机密信息:", err)
            return
        }

        // 打印读取到的值
        log.Println("读取到的值:", data.Data)
        time.Sleep(time.Second)
    }

我们运行这个程序将得到如下结果:

$go run main.go
登录Vault成功
2023/11/06 05:24:17 读取到的值: map[foo:bar]
2023/11/06 05:24:18 读取到的值: map[foo:bar]
2023/11/06 05:24:19 读取到的值: map[foo:bar]
2023/11/06 05:24:20 读取到的值: map[foo:bar]
2023/11/06 05:24:21 读取到的值: map[foo:bar]
无法读取机密信息: error encountered while reading secret at secret/data/hello: Error making API request.

URL: GET http://localhost:8200/v1/secret/data/hello
Code: 403. Errors:

* permission denied

我们看到如果token过期,而我们的程序又没有对token进行续期(renewal),程序后续对Vault中机密数据的访问将以”permission denied”的失败而告终。下面我们就来为程序加上token续期,Vault SDK提供了LifetimeWatcher来辅助token续期工作,下面就是利用LifetimeWatcher进行token续期的示例:

// secret-management-examples/auth_user_password_renewal/main.go

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/hashicorp/vault/api"
    auth "github.com/hashicorp/vault/api/auth/userpass"
)

func main() {
    user := "tonybai"
    pass := "ilovegolang"

    // 创建Vault API客户端
    client, err := api.NewClient(api.DefaultConfig())
    if err != nil {
        fmt.Printf("无法创建Vault客户端: %v\n", err)
        return
    }
    // 设置 Vault 地址
    client.SetAddress("http://localhost:8200")

    // client登录vault服务器获取临时访问令牌
    userpassAuth, err := auth.NewUserpassAuth(user, &auth.Password{FromString: pass})
    if err != nil {
        fmt.Errorf("无法初始化userpass auth method: %w", err)
        return
    }

    secret, err := client.Auth().Login(context.Background(), userpassAuth)
    if err != nil {
        fmt.Errorf("登录Vault失败: %w", err)
        return
    }
    if secret == nil {
        fmt.Printf("登录后没有secret信息返回: %v\n", err)
        return
    }
    fmt.Printf("登录Vault成功\n")

    token := secret.Auth.ClientToken

    // 设置临时访问令牌
    client.SetToken(token)

    // 设置renewel watcher
    watcher, err := client.NewLifetimeWatcher(&api.LifetimeWatcherInput{
        Secret: secret,
    })
    go watcher.Start()
    defer watcher.Stop()

    kv2 := client.KVv2("secret") // mount "secret"
    ticker := time.NewTicker(time.Second)

    for {
        select {
        case err := <-watcher.DoneCh():
            if err != nil {
                log.Printf("Failed to renew token: %v. Re-attempting login.", err)
                return
            }

            // This occurs once the token has reached max TTL.
            log.Printf("Token can no longer be renewed. Re-attempting login.")
            return

        case renewal := <-watcher.RenewCh():
            // Renewal is now over
            log.Printf("Successfully renewed: %#v", renewal)

        case <-ticker.C:
            // 每个一秒读取一次Vault的secret/data/{key}路径下的机密信息
            data, err := kv2.Get(context.Background(), "hello")
            if err != nil {
                fmt.Println("无法读取机密信息:", err)
                continue
            }
            // 打印读取到的值
            log.Println("读取到的值:", data.Data)
        }
    }
}

运行上述示例(此时token_ttl为5s):

$go run main.go
登录Vault成功
2023/11/06 05:17:42 Successfully renewed: &api.RenewOutput{RenewedAt:time.Date(2023, time.November, 7, 14, 17, 42, 233750000, time.UTC), Secret:(*api.Secret)(0xc000114a80)}
2023/11/06 05:17:43 读取到的值: map[foo:bar]
2023/11/06 05:17:44 读取到的值: map[foo:bar]
2023/11/06 05:17:45 读取到的值: map[foo:bar]
2023/11/06 05:17:45 Successfully renewed: &api.RenewOutput{RenewedAt:time.Date(2023, time.November, 7, 14, 17, 45, 841374000, time.UTC), Secret:(*api.Secret)(0xc0002827e0)}
2023/11/06 05:17:46 读取到的值: map[foo:bar]
2023/11/06 05:17:47 读取到的值: map[foo:bar]
2023/11/06 05:17:48 读取到的值: map[foo:bar]
2023/11/06 05:17:49 读取到的值: map[foo:bar]
2023/11/06 05:17:49 Successfully renewed: &api.RenewOutput{RenewedAt:time.Date(2023, time.November, 7, 14, 17, 49, 443211000, time.UTC), Secret:(*api.Secret)(0xc0002831a0)}
2023/11/06 05:17:50 读取到的值: map[foo:bar]
2023/11/06 05:17:51 读取到的值: map[foo:bar]
2023/11/06 05:17:52 读取到的值: map[foo:bar]
2023/11/06 05:17:53 Successfully renewed: &api.RenewOutput{RenewedAt:time.Date(2023, time.November, 7, 14, 17, 53, 46880000, time.UTC), Secret:(*api.Secret)(0xc000115a40)}
2023/11/06 05:17:53 读取到的值: map[foo:bar]
2023/11/06 05:17:54 读取到的值: map[foo:bar]
... ...

我们看到,在token过期之前,LifetimeWatcher帮助Client完成了续期请求。LifetimeWatcher运行在一个单独的goroutine中,通过channel与main goroutine通信。Vault默认token_max_ttl的值为32天,即便你没有设置其值,当token续期到32天时,就无法再renew了,此时watcher.DoneCh会返回事件,这是让你重新login的信号,示例中只给出了注释,并未重新login,大家注意一下。出于安全考虑,可以将token_max_ttl设置为一个合理的值,使其起到应有的安全作用。

通过这个示例我们看到,只要通过Vault的身份认证和授权,我们就能安全地存储和使用机密信息了。那么如何保证应用在与Vault进行身份认证和授权时所使用的凭据的安全呢?比如上面程序里所需的user和password。这个感觉又回到“先有鸡还是先有蛋”的问题了!实际在生产环境,我们可以依赖IaaS层或公有云的安全措施来保证,比如通过环境变量在运行时注入user和password;再比如利用公有云提供的KMS(key management system)或HSM(Hardware Security Module)服务来保证user和password安全。

4.2 静态secret

将静态secret作为机密信息保存和管理,是Vault非常常见的应用。secret可以存在很长时间不变,或可能很少改变。Vault可以使用它的加密屏障(barrier)存储这些secret,应用程序运行时可以向Vault请求读取这些secret来使用。

Vault的versioned secrets engine支持你以安全的方式存储和管理secret,同时还提供secret的版本控制能力。你可以使用不同版本的secret进行应用程序升级或回滚,也可以在需要时轻松地恢复旧版本secret。引擎还可以记录secret每个版本的修改人和修改时间。

关于静态secret的管理和使用,可以参见3.5中的基本场景,这里就不赘述了。

4.3 动态secret

有静态、长有效期的静态secret,就会有对应的动态secret。和静态secret相比,动态secret安全性高,每个动态secret的有效期都较短,并且一旦泄露可以马上撤销,同时动态secret也便于轮换,定期自动过期无需中断业务。

Vault提供了对多种针对不同系统的动态secret管理能力,包括数据库访问凭据、Active Directory账号, SSH keys和PKI certificates ,Vault针对不同系统提供了不同的secret engine。

Vault官方举了一个有关使用Database Secrets Engine实现数据库动态secret的示例,

鉴于篇幅,这里也不细说了。

4.4 其他场景

根据Vault官方文档对Vault应用场景的描述,除了静态和动态secret类机密信息,Vault可以处理以下类型的机密信息:

  • 数据加密类(Data encryption)机密信息

Vault支持将数据加密服务外包给Vault,应用只需关注数据的加密与解密,Vault负责核心密钥和加密管理。Vault还支持对数据进行传输加密与存储加密。

  • 身份识别类(Identity-Based access)机密信息

Vault支持从不同身份验证系统整合用户身份,实现统一的ACL系统,管理对系统和应用的访问。

  • 加密密钥类(Key management)机密信息

Vault支持对云提供商密钥的生命周期管理,例如管理AWS KMS或GCP云密钥。

鉴于篇幅和实验环境有限,这里就针对每种情况做详细示例说明了,大家可以根据自己的需求,针对具体的某个场景做专题性的研究。

5. 小结

本文首先介绍了机密管理的概念,阐述了在现代Web应用开发中,为何需要重视机密管理。

接着,文中概述了专用于实现机密管理的机密管理系统的发展历程,以及从功能上逐步演化出的云原生机密管理系统的特征。

文章以业内知名的开源机密管理系统HashiCorp Vault为例,全面系统地介绍了它的架构设计、安全模型、使用方法,并详细阐释了应用程序如何通过与Vault API/SDK的集成,实现对各类机密信息的安全存储、动态生成、访问控制、审计等功能。

最后,文章用代码实例详细演示了基于Vault的几个典型机密管理场景,如不同类型机密信息的读写操作,以及不同认证方式的集成等。

这是个”每个人都应该重视安全的时代”,安全需要每个环节的参与,一处薄弱,就会导致“处处薄弱”。我相信本文的内容能有助于让大家对机密管理的概念、重要性及具体实现方法有更深入的理解。

本文涉及的代码可以在这里下载。

注:Vault项目还提供了Vault Agent和Vault Proxy,旨在为应用提供更可扩展、更简单的方式来集成Vault,消除应用程序采用Vault的初期障碍。Vault Agent可以获取secrets并将它们提供给应用程序,Vault Proxy可以在Vault和应用程序之间充当代理,可选地简化认证过程并缓存请求。有兴趣的童鞋可以参考Vault Agent和Proxy的官方文档

6. 参考资料


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

通过实例理解Web应用授权的几种方式

本文永久链接 – https://tonybai.com/2023/11/04/understand-go-web-authz-by-example

在前面的系列文章中,我们了解了Go Web应用身份认证的几种方式,也知道了该如何相对安全地存储用户的密码信息,最大程度减小在系统数据库被攻破时用户密码信息的泄露程度。

一旦用户通过身份验证,他/她就可以以合法的身份进入到系统中,那么问题来了:用户进入系统后是否就可以“为所欲为”了呢?显然不是! 比如我们以普通用户身份登录github,身份验证成功后,我们只能增删改自己账号下的代码仓库数据或读取其他用户的公开仓库(public)数据,我们无法修改和删除其他用户下面的仓库数据,甚至看不到其他用户的私有仓库。Web应用系统(比如github)的这种对用户可以使用什么功能、可以访问和修改哪些数据的管理和控制,就是授权(Authorization),简称为AuthZ

在这篇文章中,我们就结合实例一起来了解一下Web应用都有哪些种授权方式。

1. 授权:基于访问控制策略的评估与决策

Go Web应用身份认证的几种方式一文中,我们简要说明了身份认证(AuthN)与授权(AuthZ)的关系与差异。授权是基于用户身份认证的基础之上的,按时间发生顺序,授权也是发生在身份认证之后的。

授权的目的是限制合法用户在系统中的操作,限制未经允许的访问。这让很多人将授权与访问控制(access control)直接划上了等号。其实授权与访问控制的确是密不可分的,但它们之间也不是可以简单划等号的。下面示意图展示了一个Web应用系统中授权与访问控制的关系:

从广义上看,授权和访问控制都属于对系统资源或信息的保护,它们的目的是限制未经授权的访问。从具体来看,访问控制关注的是如何控制主体访问对象,是一种机制或方法,它通常会定义访问模型以及相关访问策略;而授权则关注主体是否有权访问对象,是在已知用户身份以及访问控制策略下对访问请求的评估和决策过程,并得出主体是否具有访问权限的最终结果。

从上图我们也可以看到:授权是建立在访问控制之上的。访问控制定义了授权评估所需的模型、策略、机制和规则,授权则是在这套规则下,评估一个主体对一个对象的操作是否被允许,两者关系密不可分。在实现上,我们通常会联合使用“访问控制”和“授权”这两个概念,对外部更多用授权一词作为这个过程的统称。

到这里,我们理解了访问控制与授权的关系 – 访问控制提供了授权评估所需的模型和规则体系。针对不同的应用场景,IT界有几种典型的访问控制模型被提出和使用:

  • 访问控制列表(Access Control List,ACL)
  • 强制访问控制(Mandatory Access Control,MAC)
  • 自主访问控制(Discretionary Access Control,DAC)
  • 基于属性的访问控制(Attribute-Based Access Control, ABAC)
  • 基于角色的访问控制(Role-Based Access Control, RBAC)

这些模型为我们建立访问策略提供了框架和抽象工具。理解不同访问控制模型的思想和适用场景,可以帮助我们更好地制定系统的安全访问策略。在接下来的内容中,我们会简单介绍这几种访问控制模型,包括它们应用场景、优缺点等。这也将帮助大家建立访问控制和授权评估的整体视角,也可以为后续使用Go语言实现授权控制的实例提供理论基础。

2. 访问控制模型

和任何其它IT技术一样,访问控制模型也有着自己的演进历史过程。下面我们沿着模型演进的时间线,逐个认识一下各个模型。

2.1 访问控制列表(Access Control List,ACL)

ACL是最早的访问控制模型,基于ACL的访问控制会直接在被访问对象(也称为客体(O – Object))上设置允许/拒绝访问的主体(S – Subject)列表,最典型的就是Unix/Linux文件系统中文件的访问模型,如下图:

由此可见,这个模型并非专属于Web应用授权。早在上世纪60年代,它就广泛应用在操作系统中文件系统的访问权限管理。在1965年,Multics操作系统第一个实现了基于ACL模型的文件系统访问权限管理。POSIX曾推出ACL标准化草案,但后来放弃。但ACL并未因此受到打击,后来在NFSv4、Windows、Unix等系统中都有实现。之后,ACL被广泛地应用于网络领域,包括路由器、交换机以及防火墙都借助于访问控制列表来有效地控制用户对网络的访问,从而最大程度地保障网络安全。

ACL模型的优点是简单易用,但也存在灵活性差、难于扩展和满足复杂应用场景访问控制要求等不足。

ACL模型在现代Web应用中使用的越来越少,仅用于少数控制对特定资源访问权限的特定场景。例如,在静态文件服务器中,可以通过在文件系统中使用ACL来控制对文件或目录的访问权限。在复杂的现代Web应用领域,一些由ACL发展演化出的更灵活、更具扩展性的模型已经发展起来并走到了前台,比如后续即将要提到的RBAC和ABAC模型。

2.2 强制访问控制(Mandatory Access Control,MAC)

强制访问控制(Mandatory Access Control,MAC)起源于军用的多级安全系统,在MAC模型中,系统层强制执行访问控制策略,用户层无法更改。在操作系统领域,我们熟知的Linux的SELinuxAppArmor,Windows的Mandatory Integrity Control都属于MAC模型的实现。这种模型通常用于需要高度安全的环境,如军事或政府部门。

不过,MAC由于其中心化的强制控制方式,让其灵活性较差,并且实施起来相对复杂,在现代Web应用领域的使用场景有限。我理解这种MAC模型映射到Web应用领域的具体呈现就是“写死”到代码中的访问控制逻辑和授权决策逻辑,这些“系统层”逻辑是所有到达Web应用的请求必经的且无法进行配置和改变的。

一个典型的应用就是对资源根据安全等级进行的强制访问控制:用户只能访问其安全等级低于或等于自身安全等级的资源。下面是一个演示性质的代码例子:

// authz-examples/mac/main.go

// 定义安全等级
type SecurityLevel int

const (
    // 最低安全等级
    LevelLow SecurityLevel = iota
    // 中等安全等级
    LevelMedium
    // 最高安全等级
    LevelHigh
)

// 定义资源
type Resource struct {
    // 资源名称
    Name string
    // 安全等级
    Level SecurityLevel
}

// 定义用户
type User struct {
    // 用户名
    Name string
    // 安全等级
    Level SecurityLevel
}

// 定义访问控制策略
func CheckAccess(user User, resource Resource) bool {
    // 检查用户的安全等级是否高于或等于资源的安全等级
    return user.Level >= resource.Level
}

func main() {
    // 创建资源
    resource := Resource{
        Name:  "敏感数据",
        Level: LevelHigh,
    }

    // 创建用户
    user := User{
        Name:  "管理员",
        Level: LevelHigh,
    }

    // 检查访问权限
    if CheckAccess(user, resource) {
        fmt.Printf("用户[%s]有权访问资源\n", user.Name)
    } else {
        fmt.Printf("用户[%s]没有权限访问资源\n", user.Name)
    }

    // 创建用户
    user = User{
        Name:  "访客",
        Level: LevelLow,
    }

    // 检查访问权限
    if CheckAccess(user, resource) {
        fmt.Printf("用户[%s]有权访问资源\n", user.Name)
    } else {
        fmt.Printf("用户[%s]没有权限访问资源\n", user.Name)
    }
}

在这个例子中,我们定义了三个安全等级:LevelLow、LevelMedium和LevelHigh。资源和用户都被分配了安全等级。CheckAccess函数用于执行强制的访问控制策略:即用户只能访问其安全等级低于或等于自身安全等级的资源。

2.3 自主访问控制(Discretionary Access Control,DAC)

DAC模型基于资源所有者对其资源的访问权限进行授予和控制。在DAC模型中,资源的所有者可以自主决定哪些用户或实体可以访问他们的资源,以及对资源的访问权限级别。资源的所有者可以决定将资源设置为公开访问、私有访问或仅限于特定用户或用户组的访问。通过授权用户或实体访问资源,资源的所有者具有灵活性和自主权来管理他们的资源。

很显然,这样的DAC模型具有较为灵活的优点,允许资源所有者根据自己的需求和偏好授予和撤销访问权限。这使得资源的访问控制可以针对个体用户进行定制,满足不同用户的需求。同时,DAC模型具备分散控制的特征,它将访问控制的决策权下放给资源的所有者,而不是集中在中央管理机构。这样可以减轻管理负担,并且资源的所有者可以更直接地管理和控制自己的资源。

使用自主访问控制(DAC)模型进行访问控制的Web应用的典型例子是文件共享服务,即我们经常说的网盘服务,比如Google Drive、Dropbox或百度网盘等。在这样的应用中,用户可以上传、存储和共享文件。并使用DAC模型,设置文件或文件夹的访问权限,例如私有、只读或读写访问。用户还可以选择将文件或文件夹的访问权限限制为特定的用户或用户组。

另一种使用DAC模型进行访问控制的Web应用示例是社交媒体应用,这类应用允许用户发布和查看帖子,并给每个帖子分配权限以控制其他用户对其的访问权限,权限可以是“公开”、“好友”、“私人”等,

下面是一个DAC模型的演示性质的代码例子:

// authz-examples/dac/main.go

type Resource struct {
    Name      string
    Owner     string
    AccessMap map[string]bool
}

func (r *Resource) GrantAccess(user string) {
    r.AccessMap[user] = true
}

func (r *Resource) RevokeAccess(user string) {
    r.AccessMap[user] = false
}

func (r *Resource) CanAccess(user string) bool {
    access, exists := r.AccessMap[user]
    if !exists {
        return false
    }
    return access
}

func main() {
    // 创建一个资源
    resource := Resource{
        Name:      "example.txt",
        Owner:     "alice",
        AccessMap: make(map[string]bool),
    }

    // 授予访问权限给用户
    resource.GrantAccess("alice")
    resource.GrantAccess("bob")

    // 验证访问权限
    fmt.Println("alice can access:", resource.CanAccess("alice")) // 输出: true
    fmt.Println("bob can access:", resource.CanAccess("bob"))     // 输出: true
    fmt.Println("eve can access:", resource.CanAccess("eve"))     // 输出: false

    // 撤销访问权限
    resource.RevokeAccess("bob")

    // 验证访问权限
    fmt.Println("bob can access:", resource.CanAccess("bob")) // 输出: false
}

在这个示例中,我们定义了一个Resource结构,包含资源的名称、所有者和访问权限的map。用户可以调用GrantAccess方法授予其他用户对资源的访问权限,RevokeAccess方法则用于撤销用户的访问权限,CanAccess方法用于验证用户是否具有访问资源的权限。通过这个示例,我们也可以看到,MAC模型可以基于一个ACL(比如AccessMap)来实现。

DAC模型那些固有的特点带来的也并不都是好处,也可能给应用带来一定的安全性挑战。比如:由于访问权限由资源的所有者授予,因此可能存在资源所有者授予不当权限的情况。如果资源所有者错误地授予了高权限给不信任的用户或实体,可能会导致安全漏洞。

此外,由于每个资源的所有者可以独立决定访问权限,因此可能会导致系统中存在许多不一致的访问控制策略。这可能增加了管理和维护的复杂性,并且可能导致访问控制规则的碰撞或冲突。

2.4 基于角色的访问控制(Role-Based Access Control,RBAC)

按访问控制模型的出现时间看,ACL是一个60后,MAC是一个70后,DAC是一个80后。那90后的代表是哪个模型呢?没错,就是RBAC

RBAC是访问控制模型中的一种相对较新的模型,它基于角色和权限的概念来管理对资源的访问。在RBAC模型中,访问权限是根据用户的角色进行授权和控制的。

RBAC模型的核心概念包括:

  • 角色(Role)

角色代表一组具有相似职责或权限需求的用户,每个角色可以被分配不同的权限,或者说权限是以角色为最小单位分配的。

  • 权限(Permission)

权限代表对资源执行特定操作的授权。权限定义了以特定角色进入系统的用户在系统中可以对某些类资源执行的操作,例如读取、写入、删除等。

  • 用户(User)

用户是系统中的实体,通过分配角色来获得相应的权限。

下图是一个RBAC模型中用户、角色、权限与资源之间的直观的关系示意图(使用mermaid绘制):

这个图比较好理解!首先看权限,权限是一个规则,即允许哪个/哪些角色操作哪个/那些资源。以权限P1为例,它允许角色X操作资源R1和资源R2;权限P2则是允许角色Y和角色Z操作资源R2;权限P3则是允许角色Z操作资源R3。用户则会被赋予角色,并继承角色具有的所有权限。

通过上面图示和说明,我们看到:RBAC模型通过将权限分配给角色,而不是直接分配给用户,简化了权限管理,因为只需管理角色的权限,而无需单独管理每个用户的权限。

同时,这种方法也保持了一定的灵活性:通过分配和撤销角色,可以轻松地管理用户的访问权限。当用户的职责或权限需求发生变化时,只需调整其角色分配即可。

在安全性方面,RBAC模型可以减少人为错误和误操作的风险。通过严格控制角色的权限,可以确保用户只能执行他们所需的操作,从而减少潜在的安全漏洞。

基于上述特点,RBAC模型被广泛应用于企业环境中,并满足企业或组织内部的权限管理需求,是当今企业级Web应用的主流访问控制模型

此外,像Github的Personal Access Token(PAT)以及其他互联网Web应用的类似PAT的权限配置也是基于RBAC模型的。使用PAT时,用户可以创建令牌并为其分配特定的范围和权限,这时令牌既是user,也充当了角色(Role)。这些权限可以控制PAT可以访问和执行的操作,例如读取仓库、创建存储库、管理问题等。用户可以根据自己的需求创建多个PAT(Role),并根据需要撤销或更新它们。下面是github PAT创建和配置的示意图:

不过,RBAC模型虽然是主流模型,但也存在一些问题,比如:

  • 静态角色分配

RBAC模型中,角色和权限的分配是静态的,需要预先定义和分配角色。这种固定的角色分配方式难以适应动态变化的访问控制需求。例如,当用户的职责发生变化或需要临时获得额外权限时,RBAC模型需要进行角色重新分配或角色继承的操作,导致管理复杂性增加。

  • 角色爆炸问题

在大型组织或系统中,RBAC模型可能涉及大量的角色,以覆盖各种职责和权限。这可能导致角色爆炸问题,即角色数量过多,不易管理和维护。角色之间的关系和权限的粒度也可能变得复杂,增加了配置和管理的复杂性。

  • 缺乏细粒度访问控制

RBAC模型的主要限制之一是对访问控制的粒度较粗。RBAC模型通常基于角色来控制访问权限,而忽略了更细粒度的访问控制需求,如基于资源属性、环境上下文等进行访问控制。

  • 缺乏动态性和灵活性

RBAC模型的角色和权限分配是静态的,难以适应动态变化的访问控制需求。RBAC模型无法根据实时上下文信息或动态的用户属性来进行访问控制决策,导致难以满足复杂的访问控制策略。

这些问题也促成了00后的新模型ABAC的出现,下面我们就来看看ABAC模型。

2.5 基于属性的访问控制(Attribute-Based Access Control,ABAC)

ABAC,有时也被称为policy-based access control (PBAC)或claims-based access control (CBAC),是一种基于属性(Attribute)来决定对资源的访问权限的访问控制模型。与“90后”的RBAC模型相比,ABAC模型提供了更细粒度、动态和灵活的访问控制能力。

ABAC模型的核心概念包括如下几个:

  • 属性(Attribute)

属性是关于用户、资源、环境或其他上下文信息的特征。属性可以是任意对象,一般有这么几类。访问主体(用户)属性,可以是访问者自带的属性,比如年龄,性别,部门,角色等;动作类属性:比如读取,删除,查看等;被访问对象的属性,比如一条记录的修改时间,创建者等;环境类属性:比如时间信息,地理位置信息,访问平台信息等。属性还可以根据需要进行自定义和扩展。

  • 策略(Policy)

策略定义了访问控制规则和条件,用于评估访问请求的属性和上下文信息,并决定是否允许或拒绝访问。策略可以包括属性匹配、逻辑操作、时间条件等。

  • 属性策略引擎(Policy Decision Point, PDP)

属性策略引擎是ABAC模型的核心组件,负责评估访问请求的属性和条件,并根据属性和预定义的策略进行逻辑计算,以做出是否允许访问的控制决策。

下面是一个使用ABAC模型在组织内控制对某个文件资源的访问的例子的示意图:

在这个示例中,用户属性包括用户角色(Role)、用户部门(Department)、用户年龄(Age);资源属性有文件类型(FileType)、文件所属部门(FileDepartment)。

属性策略引擎(PDP)通过PIP(策略信息点)获取相关属性,并基于策略做出决策。下面是一些策略示例:

策略1:仅允许具有"管理员"角色的用户访问任意类型的文件。
策略2:仅允许具有"员工"角色的用户访问属于自己部门的任意类型的文件。
策略3:允许具有"访客"角色的用户访问公共类型的文件。
策略4:允许60岁以上用户访问特定类型的文件。
策略5:不允许访问属于其他部门的文件。

图中的PEP(策略执行点)负责接收用户发起的访问请求,并将请求传递给PDP进行决策,确保访问控制策略得到严格执行,以保护资源的安全性和完整性。用户可以是人员、应用程序或其他实体。之后,PEP负责根据访问控制策略的决策结果来执行实际的访问控制。当PDP(Policy Decision Point,属性策略引擎)确定用户是否被授权访问资源后,PEP将根据决策结果来允许或拒绝对资源的访问。PEP还可以记录和监控访问请求和决策结果,用于后续的审计和安全分析。通过记录访问活动,PEP可以提供有关谁、何时以及如何访问资源的详细信息。

ABAC是较新的访问控制模型,相较于它的前辈RBAC来说,它的能力更强大,控制粒度更精细,授权决策动态且灵活,但也更加复杂,需要定义和管理大量的属性和策略。这可能增加了实施和维护的困难。另外,ABAC模型的属性策略引擎需要对访问请求进行属性匹配和逻辑计算,这可能对系统性能产生一定的影响,在引入ABAC模型时,这也是一个需要考虑的因素。

尽管ABAC模型存在一些挑战和复杂性,但它提供了更高级、动态和灵活的访问控制能力让它可以更好地满足复杂的访问控制需求和安全策略,这使得ABAC模型在许多组织和行业中得到广泛应用,包括企业、政府、云计算、物联网等。同时,有许多标准化组织和机构致力于制定ABAC的标准和规范,例如NIST(美国国家标准与技术研究院)的NGACOASIS的XACML(eXtensible Access Control Markup Language),这些标准化努力有助于推动ABAC的统一实施和互操作性。

随着技术的不断进步,ABAC模型也在不断演进和改进。例如,引入了机器学习和人工智能技术来提高决策过程的自动化和智能化。

到这里,我们已经学习了从ACL到ABAC的5种主要访问控制模型,包括它们的发展历史、应用场景、优缺点等,这给我们提供了对不同访问控制模型的全面了解和比较。如今RBAC和ABAC是大家广泛应用的主流模型,接下来,我们就以一个示例来进一步加深这两个模型的理解。我将构建一个简单的公司员工信息管理系统,并在此系统中分别实现基于RBAC和ABAC的访问控制机制,以便通过对比不同实现来直观感受两种模型的区别。

3. 一个Web应用的授权实例

下面我们用一个Web应用的授权实例来进一步理解RBAC和ABAC两个广泛使用的访问控制模型。这是一个公司员工信息管理系统授权访问控制的示例,我们先用casbin以RBAC模型实现该示例,之后使用Open Policy Agent以ABAC模型再实现一遍该示例。

3.1 示例简介

好的,现在给你描述一下这个示例对授权的具体要求。

这是一个公司的员工信息管理系统,系统中定义了几种角色(role):经理(manager)、员工(employee)、HR和财务(finance)。这些角色可以直接用于RBAC模型中的角色,在ABAC中,它也可以作为主体(subject)的角色属性使用。系统要保护的资源有两个表:员工信息表(employee_info)和员工工资表(employee_salary),并定义了如下访问控制要求:

  • 经理:可以查看和修改所有员工的信息
  • 员工:可以查看和修改自己的信息
  • HR:可以查看和修改所有员工的信息,可以查看和修改所有员工的工资信息
  • 财务:可以查看所有员工的工资信息

这个公司有如下几位不同角色的员工:

  • 经理:alice
  • 员工:bob
  • HR:cathy
  • 财务:dan

接下来我们就先来基于RBAC实现该系统的访问控制。

3.2 基于RBAC模型的访问控制

根据前面的介绍,RBAC模型是基于角色的访问控制,因此针对上面示例描述,我们需要先定义角色以及为角色分配权限。

casbin使用policy.csv定义角色的权限:

// authz-examples/rbac/casbin/policy.csv

p, manager, employee_info, read
p, manager, employee_info, write
p, employee, employee_info, write
p, employee, employee_info, read
p, hr, employee_info, read
p, hr, employee_info, write
p, hr, employee_salary, write
p, hr, employee_salary, read
p, finance, employee_salary, read

初看这个文件中的配置数据,很多人不知道是什么意思,这个csv文件中每行的字段含义都要与model.conf对照着看:

// authz-examples/rbac/casbin/model.conf

[request_definition]
r = role, obj, act

[policy_definition]
p = role, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow)) 

[matchers]
m = g(r.role, p.role) && r.obj == p.obj && r.act == p.act

我们看下这个配置文件中的section: policy_definition,我们看到其定义为p = role, obj, act,这里的p就是规则定义,根据这一规则定义,我们可以确定csv中p开头的行中的各段数据的含义,比如:根据“p, manager, employee_info, read”,我们可以得到role=manager,obj=employee_info,act=read,我们用下图再直观总结一下这种对应关系:

request_definition这个section中定义了请求传入时参数的次序(“r = role, obj, act”),其要求Go代码中调用casbin.Enforcer.Enforce方法时各个参数的传入次序与之相同(比如: Enforce(“manager”, “employee_info”, “read”)),并指示了传入的参数对应的含义。

matchers这个section中定义了匹配规则,先不看g(r.role, p.role),“r.obj == p.obj && r.act == p.act”这个很好理解,即当请求(request)中的obj与策略规则(policy)中的obj匹配,且请求中的act与策略规则中的act(动作)匹配时,才认为通过访问控制的校验。

我们结合Go代码来看一下casbin对RABC的实现和使用方法:

// authz-examples/rbac/casbin/main.go

package main

import (
    "fmt"

    "github.com/casbin/casbin/v2"
)

func main() {
    users := map[string]string{
        "alice": "manager",
        "bob":   "employee",
        "cathy": "hr",
        "dan":   "finance",
    }

    e, err := casbin.NewEnforcer("model.conf", "policy.csv")
    if err != nil {
        panic(err)
    }

    // 经理alice访问员工信息
    ok, err := e.Enforce(users["alice"], "employee_info", "read") // role, obj, act
    if err != nil {
        panic(err)
    }
    fmt.Println("manager alice can read employee_info:", ok)

    ok, err = e.Enforce(users["alice"], "employee_info", "write")
    if err != nil {
        panic(err)
    }
    fmt.Println("manager alice can write employee_info:", ok)

    // 员工bob访问自己信息
    ok, err = e.Enforce(users["bob"], "employee_info", "write")
    fmt.Println("employee bob can write employee_info:", ok)

    // HR cathy 访问员工信息
    ok, err = e.Enforce(users["cathy"], "employee_info", "write")
    fmt.Println("hr cathy can write employee_info:", ok)
    ok, err = e.Enforce(users["cathy"], "employee_salary", "write")
    fmt.Println("hr cathy can write employee_salary:", ok)

    // 财务dan访问工资信息
    ok, err = e.Enforce(users["dan"], "employee_salary", "read")
    fmt.Println("finance dan can read employee_salary:", ok)

    // 员工bob串改薪水信息
    ok, err = e.Enforce(users["bob"], "employee_salary", "write")
    fmt.Println("employee bob can write employee_salary:", ok)
}

这里只是企业内部信息系统的简化实现,正常情况下,员工使用自己的账号登录到系统后,系统就会获知该用户的角色(role),这里我们用了一个map来存储用户名与角色的映射关系。

我们基于model.conf和policy.csv创建新的Enforcer,然后调用其Enforce方法并按“role, obj, act”次序传入我们要测试的信息。Enforce返回true表示通过了访问控制规则的验证,否则就是没有通过授权验证。

上述代码的输出结果如下:

$go run main.go
manager alice can read employee_info: true
manager alice can write employee_info: true
employee bob can write employee_info: true
hr cathy can write employee_info: true
hr cathy can write employee_salary: true
finance dan can read employee_salary: true
employee bob can write employee_salary: false

到这里,我们还有一个问题没有解决,那就是casbin的model.conf中role_definition的配置含义以及matchers中g(r.role, p.role)含义。

casbin关于RBAC的文档中明确提到了,如果不使用RBAC模型,那么role_definition就是一个可选的配置;如果要使用RBAC模型,那么role_definition下的每一行都是一个独立的RBAC系统,下面的配置拥有两个独立的RBAC系统:g和g2:

[role_definition]
g = _, _
g2 = _, _

“_, _”表示映射关系中有两方。通常我们只会使用到user和role的映射,因此只用一个RBAC系统即可,即只配置和使用g即可。下面是应用g这个RBAC系统的例子:

// policy.csv
p, manager, employee_info, write
g, alice, manager

这里的“g, alice, manager”的含义是alice是角色manager中的一员,或alice这个user的角色是manager。当然alice这个位置上不仅可以使用user,也可以使用resource,甚至是role,casbin只是将其看成一个字符串而已。

而matchers中的“g(r.role, p.role)”的含义就是请求(r)中的role在policy文件中能找到对应的role。如果Enforce函数传入的是”manager”,那么只有policy.csv中定义了”manager”这个角色,g(r.role, p.role)的结果才是true。

上述示例并未在policy.csv中使用到这种user到role的映射(基于g这个RBAC系统),下面我们改造一下示例,我们在policy.csv中保存这种user到role的映射,而不是在go代码中保存,新版policy.csv如下:

// authz-examples/rbac/casbin_with_user_in_policy/policy.csv 

p, manager, employee_info, read
p, manager, employee_info, write
p, employee, employee_info, write
p, employee, employee_info, read
p, hr, employee_info, read
p, hr, employee_info, write
p, hr, employee_salary, write
p, hr, employee_salary, read
p, finance, employee_salary, read
g, alice, manager
g, bob, employee
g, cathy, hr
g, dan, finance

对应的Go代码改造如下:

// authz-examples/rbac/casbin_with_user_in_policy/main.go

func main() {
    e, err := casbin.NewEnforcer("model.conf", "policy.csv")
    if err != nil {
        panic(err)
    }

    // 经理alice访问员工信息
    ok, err := e.Enforce("alice", "employee_info", "read") // role, obj, act
    if err != nil {
        panic(err)
    }
    fmt.Println("manager alice can read employee_info:", ok)

    ok, err = e.Enforce("alice", "employee_info", "write")
    if err != nil {
        panic(err)
    }
    fmt.Println("manager alice can write employee_info:", ok)

    // 员工bob访问自己信息
    ok, err = e.Enforce("bob", "employee_info", "write")
    fmt.Println("employee bob can write employee_info:", ok)

    // HR cathy 访问员工信息
    ok, err = e.Enforce("cathy", "employee_info", "write")
    fmt.Println("hr cathy can write employee_info:", ok)
    ok, err = e.Enforce("cathy", "employee_salary", "write")
    fmt.Println("hr cathy can write employee_salary:", ok)

    // 财务dan访问工资信息
    ok, err = e.Enforce("dan", "employee_salary", "read")
    fmt.Println("finance dan can read employee_salary:", ok)

    // 员工bob串改薪水信息
    ok, err = e.Enforce("bob", "employee_salary", "write")
    fmt.Println("employee bob can write employee_salary:", ok)
}

大家看到我们在调用Enforce时,第一个参数传入的不再是role,而是user名字,由于policy.csv中使用g保存了user到role的映射,因此Enforce会在内部将user先替换为映射的role,然后再在policy.csv中找到对应的p定义的role,查看是否满足matchers中“g(r.role, p.role)”规则。

运行上面新示例的结果将于第一个示例一样,这里就不赘述了。

接下来,我们再来看看如何基于ABAC模型实现该公司的员工信息系统的授权。

注:casbin号称也支持ABAC模型,有兴趣的童鞋可以自行基于casbin实现基于ABAC模型的员工信息系统的授权示例。

3.3 基于ABAC模型的访问控制

前面介绍ABAC模型时已经提到过,ABAC是基于属性的访问控制,由于我们这个示例比较简单,能用到的user主体属性只有user的角色(role),这里就基于user的角色来实现访问控制,而作为客体的那两张表,考虑简单起见,这里并未为之定义什么属性。

OPA(Open Policy Agent)是CNCF基金会下面的一个开源的通用策略引擎,它目前已经从CNCF毕业,也是CNCF目前毕业项目中唯一一个策略引擎。OPA可以用于实现统一的访问控制和策略管理。它提供了一个通用的框架,可用于编写和执行策略,以决定对资源的访问是否被允许。OPA使用一种名为Rego的声明性语言来定义策略。Rego语言简洁而强大,可以表达复杂的访问控制逻辑。它允许开发人员定义规则、条件和约束,以描述访问策略和决策过程。受益于CNCF的支持和资源,OPA获得了更广泛的知名度和认可度。它成为了云原生生态系统中重要的一部分,并与其他CNCF项目和工具进行紧密的集成,如Kubernetes、Envoy、Prometheus等。这种集成加强了整个生态系统的互操作性和一致性,为用户提供了更强大的功能和灵活性。

使用opa实现员工信息系统的ABAC授权,我们需要先使用Rego语言定义出访问控制策略,下面是用Rego定义的员工信息系统的访问控制策略:

// authz-examples/abac/opa/policy.rego

package opa.examples

import input as i

# 定义策略
allow {
  i.subject.role == "manager"
  i.object == "employee_info"
  i.action == "read"
}

allow {
  i.subject.role == "manager"
  i.object == "employee_info"
  i.action == "write"
}

allow {
  i.subject.role == "employee"
  i.object == "employee_info"
  i.action == "read"
}

allow {
  i.subject.role == "employee"
  i.object == "employee_info"
  i.action == "write"
}

allow {
  i.subject.role == "hr"
  i.object == "employee_info"
  i.action == "read"
}

allow {
  i.subject.role == "hr"
  i.object == "employee_info"
  i.action == "write"
}

allow {
  i.subject.role == "finance"
  i.object == "employee_salary"
  i.action == "read"
}

这个策略配置文件使用的语法借鉴了Go,不过即便你不了解Go语法,你很大概率也能读懂其逻辑,是不是感觉比前面的casbin的model.conf和policy.csv的组合配置更易理解一些呢!我们以一个allow代码块为例:

allow {
  i.subject.role == "finance"
  i.object == "employee_salary"
  i.action == "read"
}

这个配置块儿的含义就是当输出的请求中的主体的role为”finance”且客体(resouce)为”employee_salary”并且action为”read”时,允许请求访问。其他的section依此理解即可。

下面我们再来看看基于opa实现上述ABAC模型的Go代码:

// authz-examples/abac/opa/main.go

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/open-policy-agent/opa/rego"
)

func main() {
    // Construct a Rego object that can be prepared or evaluated.
    r := rego.New(
        rego.Query("data.opa.examples.allow"),
        rego.Load([]string{"./policy.rego"}, nil),
    )

    // Create a prepared query that can be evaluated.
    query, err := r.PrepareForEval(context.Background())
    if err != nil {
        log.Fatal(err)
    }

    inputs := []map[string]interface{}{
        {
            "name": "alice",
            "subject": map[string]string{
                "role": "manager",
            },
            "object": "employee_info",
            "action": "read",
        },
        {
            "name": "alice",
            "subject": map[string]string{
                "role": "manager",
            },
            "object": "employee_info",
            "action": "write",
        },
        {
            "name": "bob",
            "subject": map[string]string{
                "role": "employee",
            },
            "object": "employee_info",
            "action": "write",
        },
        {
            "name": "cathy",
            "subject": map[string]string{
                "role": "hr",
            },
            "object": "employee_info",
            "action": "read",
        },
        {
            "name": "cathy",
            "subject": map[string]string{
                "role": "hr",
            },
            "object": "employee_info",
            "action": "write",
        },
        {
            "name": "dan",
            "subject": map[string]string{
                "role": "finance",
            },
            "object": "employee_salary",
            "action": "read",
        },
        {
            "name": "bob",
            "subject": map[string]string{
                "role": "employee",
            },
            "object": "employee_salary",
            "action": "write",
        },
    }

    for _, v := range inputs {
        // Execute the prepared query.
        rs, err := query.Eval(context.Background(), rego.EvalInput(v))
        if err != nil {
            log.Fatal(err)
        }

        if len(rs) > 0 {
            fmt.Printf("%s %s can %s %s: %v\n", (v["subject"].(map[string]string))["role"], v["name"],
                v["action"], v["object"], rs[0].Expressions[0].Value)
        } else {
            fmt.Printf("%s %s can %s %s: %v\n", (v["subject"].(map[string]string))["role"], v["name"],
                v["action"], v["object"], false)
        }

    }
}

这个例子参考了opa官方的示例,我们先基于policy.rego构建一个rego策略引擎,然后按我们的测试逻辑构建一组input,我将input放入了一个map切片中,然后遍历该切片,对每个input执行Eval,通过Eval返回的结果判断input是否通过了引擎的校验。执行上述示例代码,我们将得到:

$go run main.go
manager alice can read employee_info: true
manager alice can write employee_info: true
employee bob can write employee_info: true
hr cathy can read employee_info: true
hr cathy can write employee_info: true
finance dan can read employee_salary: true
employee bob can write employee_salary: false

这个和casbin实现的结果是一致的。

通过上面两种模型的实现,我们能达到相同的效果。不过,opa的rego语言的简洁清晰且不乏强大的表达能力还是让人印象深刻的,casbin的配置在理解上要下一番功夫,并且要用好casbin,还必须要深入理解其配置方法和配置项的含义。这两个工具大家可以根据自己的喜好选择最适合你自己的。

以上无论是RBAC,还是ABAC,都是仅由本地单系统参与的授权模型。随着系统规模的扩大,我们可能需要考虑引入第三方授权系统。第三方授权具有方便实现单点登录、用户友好的授权流程、减少密码传播风险、细粒度的授权管理以及第三方应用程序集成等好处。这些好处可以提供更方便、安全和灵活的用户体验,并促进了应用程序之间的互操作性和集成性。

接下来我们就来说说基于OAuth2的第三方授权。

4. OAuth2授权框架

4.1 什么是第三方授权

在开始理解OAuth2授权框架之前,我们先来简单说说什么是第三方授权。为了更好的说明,我先画了一张示意图:

结合这张图,我们理解以下第三方授权。第三方授权是指一个实体(第三方,比如图中的C应用),通过获得用户的授权,可以访问另一个实体(服务提供者,比如图中的S应用)的资源(比如用户A的一些个人信息)或执行特定操作。在这种授权模式下,用户授予第三方应用程序(C应用)或服务访问其受保护资源(位于S应用中的用户A的一些个人信息)的权限,而无需直接向第三方实体(比如C应用)共享其凭据(如用户名和密码)。

第三方授权的典型示例是用户使用自己的社交媒体账号(如微信、Facebook、Google、Twitter等)登录第三方应用程序或网站:

在这种情况下,用户不需要创建新的账号和密码,而是选择使用其社交媒体账号进行登录。当用户同意授权该应用程序访问其社交媒体账号时,第三方应用程序可以获取用户的基本信息(如姓名、电子邮件地址、头像等)或者在用户的名义下执行某些操作(如发布推文、分享内容等)。下面是使用github和微信对第三方应用进行授权的页面截图:

第三方授权的优势在于用户可以方便地使用现有的身份验证凭据,而无需为每个应用程序创建和记住不同的账号和密码。同时,用户还可以更好地控制其数据的访问权限,选择性地授权应用程序可以访问的资源和操作。需要注意的是,第三方授权的安全性和隐私保护至关重要。用户应该仔细审查并理解第三方应用程序请求的权限范围,并只授权其信任的应用程序访问其敏感信息或执行敏感操作。服务提供者也应该采取适当的安全措施,确保用户的数据得到妥善保护。

这样的第三方授权在移动互联网应用领域十分常见,如果没有一套标准的授权框架,这种授权方式将很难实现。OAuth正是为了解决这个问题而诞生的一个标准的授权框架。接下来,我们就进入OAuth协议框架。

4.2 OAuth协议框架

OAuth协议(全称Open Authorization)的产生是为了解决无须共享密码的情况下,从第三方应用程序(比如前面图中的S应用)安全地访问受保护数据、资源的问题。OAuth是一种行业标准的授权框架,它在第三方应用授权中发挥重要作用。OAuth协议的最新版本为OAuth2.0,并已经被广泛用于各厂家的互联网应用中。在原理上,OAuth2允许用户授权第三方应用,访问该用户在某服务平台存储的资源,而无需共享用户名和密码。它通过“访问令牌(access token)”实现授权。

注:从上述描述我们也能看出:所谓第三方授权其实是将身份认证与授权合为一体的一种机制,以授权为主要目的。因此OAuth被称为授权协议,而不是身份认证协议。

在OAuth协议的核心规范中,对于OAuth的授权流程定义了不同的角色,通过不同角色之间不同概念的信息传递对象的交互,完成整个授权流程。这些角色包括:

  • 资源所有者(Resource Owner)

资源所有者是指受保护资源的所有者,当受保护资源被访问时,需要此所有者授予访问者访问权限。如果资源所有者是一个自然人时,即表示为最终用户(比如前面图中的用户A)。

  • 资源服务器(Resource Server)

资源服务器是指托管接受保护资源的服务器(比如前面图中的S应用),接收访问请求并使用访问令牌保护受保护的资源。

  • 客户端(Client)

这里客户端通常是指代理用户发起受保护资源请求的客户端应用程序,比如前面图中的C应用。

  • 授权服务器(Authorization Server)

客户端通过认证后,授权服务器(比如前面图中的S应用)会向客户端发布访问令牌并获得授权。

访问令牌是客户端应用程序访问受保护资源的凭据,没有访问令牌则无法访问受保护的资源。此令牌通常是授权服务器颁发的具有一定含义的字符串,包含此次授权的基本信息、授权范围、授权有效时间等信息。

授权过程与我们前面的示意图十分相似,结合OAuth协议定义的不同角色,我们借鉴下面示意图再来描述一下基于OAuth2的整个授权流程:


图来自《API安全技术与实战》一书

  • 步骤1:客户端应用程序向资源所有者发送授权请求,这里的客户端是指普通的WebAPI、原生移动App、基于浏览器的Web应用以及无浏览器的嵌入式后端应用,在流程中充当用户行为代理(比如前图中的C应用)。
  • 步骤2:资源所有者(比如前图中的用户A)同意授权客户端访问资源,即获得资源所有者的授权凭据,包含授权范围和授权类型。
  • 步骤3:客户端使用上一步获得的授权凭据,向授权服务器进行身份认证并申请访问令牌Access Token。
  • 步骤4:授权服务器对客户端进行身份认证,确认身份无误后,下发访问令牌AccessToken。
  • 步骤5:客户端使用上一步获得的访问令牌Access Token,向资源服务器申请获取受保护的资源。
  • 步骤6:资源服务器确认访问令牌Access Token正确无误后,向客户端开放所访问的资源。

OAuth协议核心文档定义了资源所有者给予客户端授权的4种方式:

  • 授权码(authorization code)

这种方式下,第三方应用先申请一个授权码,然后再用该码获取令牌。

  • 隐藏式(implicit)

适用于没有后端的纯前端应用,客户端直接获得访问令牌Access Token,而无须客户端授权码这个中间步骤。

  • 密码式(password)

资源所有者的认证凭据(即用户名和密码)直接告诉第三方应用,该应用随即使用密码申请令牌。

  • 凭证式(client credentials)

适用于没有前端的命令行应用,即在命令行下请求令牌。采用客户端自己的凭据,而不是用户的凭据来作为授权依据,获取资源的访问权限。

在这四种授权方式中,授权码是OAuth协议中主要的授权流程,相比其他的授权模式,其流程最为完备,适用于互联网应用的第三方授权场景。

由于OAuth授权相对较为复杂,涉及角色和环节很多,很难用一个例子将其全貌展现出来,这里就不举代码示例了。如果你的系统并不涉及到第三方,既不为第三方提供服务,也不使用第三方的服务,那引入OAuth 2.0其实就没必要。

5. 小结

本文首先介绍了授权的相关概念,着重说明了授权与访问控制的紧密联系和些许差别。之后,我们对5种常见的访问控制模型逐一做了说明,包括它们的使用场景与优缺点。为了帮助大家更好地理解当今主流使用的RBAC和ABAC模型,我还将一个示例分别用casbin和opa作了实现。

在文章的最后,我们简单介绍了用于第三方授权的OAuth授权框架,包括它的协议中涉及的主要角色以及资源所有者给予客户端授权的4种方式,大家可以根据自己的理解,自行基于像微信或github这样的支持三方授权的应用编写一些简单示例。

本文示例所涉及的Go源码可以在这里下载。

6. 参考资料


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats