授权

授权( Authorization)

系统如何控制一个用户该看到哪些数据、能操作哪些功能?

“授权”这个行为通常伴随着“认证“、"审计"、”账号“共同出现,并称为AAAA(Authentication、Authorization、Audit、Account,一些领域也有把Account解释为计费的意思)。授权行为在程序中其实非常普遍,我们给一个类、一个方法设置范围控制符(public、protected、private、<Package>),这其实也是一种授权(访问控制)行为。授权涉及到了两个相对独立的问题:

  • 确保授权的过程可靠:对于单一系统来说,授权的过程是比较容易做到可控的,以前很多语境上提到授权,实质上讲的都是访问控制,理论上两者是应该分开的。而在涉及多方的系统中,授权过程就是一个必须严肃对待的问题:如何即让第三方系统能够访问到所需的资源,又能保证其不泄露用户的敏感数据?现在常用的多方授权协议主要有OAuth2和SAML 2.0(注意这两个协议涵盖的功能并不是直接对等的)。

  • 确保授权的结果可控:授权的结果往往是用于对程序功能或者资源的访问控制(Access Control),形成理论的权限控制模型有:自主访问控制(Discretionary Access Control,DAC)、强制访问控制(Mandatory Access Control,MAC)、基于属性的权限验证(Attribute-Based Access Control,ABAC)还有最为常用,也相对通用的是基于角色的权限模型(Role-Based Access Control,RBAC)。

由于篇幅原因,在这个小节里我们只介绍(将要)使用到的,也是最常用到的RBAC和OAuth2。先来说较为简单的RBAC。

RBAC

所有的访问控制模型,实质上都是在解决同一个问题:“(User)”拥有什么"权限(Authority)"去操作哪些“资源(Resource)”

这个问题看起来并不难,最直观的解决方案就是在用户对象上,设定一些操作权限,在使用资源时,检查是否有对应的操作权限即可。是的,请不要因太过简单直接而产生疑惑,很多著名的安全框架,譬如Spring Security的访问控制本质上就是这么做的。不过,这种把操作权限直接关联在用户身上的简单设计,在复杂系统上确实会导致比较繁琐的操作。试想一下,如果某个系统涉及到成百上千的资源,又有成千上万的用户,要为每个用户分配合适的权限将带来务必庞大的操作量和极高的出错概率,这也即是RBAC所要解决的问题。

为了避免对每一个用户设定权限,RBAC将权限从用户身上剥离,改为绑定到“角色(Role)”上,一种我们常见的RBAC应用就是操作系统权限中的“用户组”,这就是一种角色。用户可以隶属与一个或者多个角色,某个角色中也会包含有多个用户,角色之间还可以有继承性(父、子角色的权限继承,RBAC-1)。这样,资源的操作就只需按照有限且相对固定的角色去分配操作权限,而不去面对随时会动态增加的用户去分配。当用户的职责发生变化时,在系统中就体现为改变他所隶属的角色,譬如将“普通用户角色”改变“管理员角色”,就可以迅速完成其权限的调整,降低了权限分配错误的风险。RBAC的主要元素之间的关系可以以下图来表示:

graph LR User("用户(User)") --隶属--> Role("角色(Role)") Role --拥有-->Permission("许可(Permission)")

上图中出现了一个新的名词“许可(Permission)”。所谓的许可,就是抽象权限的具象化体现。权限在系统中的含义应该是“允许何种操作作用于哪些数据之上”,这个即为“许可”。举个具体的例子,譬如某个文章管理系统的UserrStory中,与访问控制相关的Backlog可能会是这样描述的:

Backlog

周同学(User)是某SCI杂志的审稿人(Role),职责之一是在系统中审核论文(Authority)。在审稿过程(Session)中,当他认为某篇论文(Resource)达到了可以公开发表标准时,就会在后台点击通过按钮(Operation)来完成审核。

以上,“给论文点击通过按钮”就是一种许可(Permission),它是“审核论文”这项权限(Authority)的具象化体现。

与微服务架构中的完全遵循RBAC进行访问控制的Kubernetes不同,我们在单体架构中使用的Spring Security参考了但并没有完全按照RBAC来进行设计。Spring Security的设计里用户和角色都可以拥有权限,譬如在HttpSecurity对象上,就同时有着hasRole()和hasAuthority()方法,可能有不少刚接触的人会疑惑,混淆它们之间的关系。在Spring Security的访问控制模型可以认为是下图所示这样的:

graph LR User("用户(User)") --隶属--> Role("角色(Role)") User --赋予--> Authority("权限(Authority)") Role --拥有-->Permission("许可(Permission)") Authority --拥有--> Permission

站在代码实现的角度来看,Spring Security中Role和Authority的差异很小,它们共同存储在同一位置,唯一的差别仅是Role会在存储时自动带上“ROLE_”前缀(可以配置的)罢了。

但在使用者的角度来看,Role和Authority的差异可以很大,你可以执行决定你的系统中到底Permission只能对应到角色身上,还是可以让用户也拥有某些角色中没有的权限。这个观点,在Spring Security自己的文档上说的很清楚:这取决于你自己如何使用。

The core difference between these two is the semantics we attach to how we use the feature. For the framework, the difference is minimal – and it basically deals with these in exactly the same way.

使用RBAC,你可以控制最终用户在广义和精细级别上可以做什么。您可以指定用户是管理员,专家用户还是普通用户,并使角色和访问权限与组织中员工的身份职位保持一致。仅根据需要为员工完成工作的足够访问权限来分配权限。

OAuth2

简要介绍过RBAC,下面我们再来看看相对要复杂繁琐一些的OAuth2认证授权协议(顺带一提,更繁琐的OAuth1已经完全被废弃了,勿念)。先明确一件事情,OAuth2是一个多方系统中的认证授权协议,如果你的系统并不涉及到第三方(譬如我们单体架构的Bookstore,即不为第三方提供服务,也不使用第三方的服务),引入OAuth2其实并无多大必要。我们之所以把OAuth2提前引入,主要是为了给微服务架构做铺垫。

OAuth2是在RFC 6749中定义授权协议,在RFC 6749正文的第一句就明确了OAuth2是解决第三方应用(Third-Party Application)的认证授权协议。前面也说到,如果只是单方系统,授权过程是比较容易解决的,至于多方系统授权过程会有什么问题,这里举个现实的例子来说明。

譬如你现在正在阅读的这个网站(https://icyfenix.cn),它的建设和更新大致流程是:笔者以Markdown形式写好了某篇文章,上传到由GitHub提供的代码仓库,接着由Travis-CI提供的持续集成服务会检测到该仓库发生了变化,触发一次Vuepress编译活动,生成目录和静态的HTML页面,然后推送回GitHub Pages,再触发腾讯云CDN的缓存刷新。这个过程要能顺利进行,就存在一些必须解决的授权问题,Travis-CI只有得到了我的明确授权,GitHub才能同意它读取我代码仓库中的内容,问题是它该如何获得我的授权呢?一种简单粗暴的方案是我把我的用户账号和密码都告诉Travis-CI,但这显然导致了以下这些问题:

  • 密码泄漏:如果Travis-CI被黑客攻破,将导致我GitHub的密码也同时被泄漏
  • 访问范围:Travis-CI将有能力读取、修改、删除、更新我放在GitHub上的所有代码仓库
  • 授权回收:我只有修改密码才能回收授予给Travis-CI的权力,可是我在GitHub的密码只有一个,授权的应用除了Travis-CI之外却还有许多,修改了意味着所有别的第三方的应用程序会全部失效

以上出现的这些问题,也就是OAuth2所要解决的问题,尤其是没有HTTPS支持传输安全的环境下依然可以解决这些问题。OAuth2提出的解决办法是通过一个令牌(Token)代替用户密码作为授权的凭证,有了令牌之后,哪怕令牌被泄漏,也不会导致密码的泄漏,令牌上可以设定访问资源的范围以及时效性,每个应用都持有独立的令牌,哪个失效都不会波及其他,一下子上面提出的三个问题都解决了,有了一层令牌之后,整个授权的流程如下图所示:

sequenceDiagram 第三方应用->>+资源所有者: 要求用户给予授权 资源所有者-->>-第三方应用: 同意给予该应用授权 第三方应用->>+授权服务器: 我有用户授权,申请访问令牌 授权服务器-->>-第三方应用: 同意发放访问令牌 第三方应用->>+资源服务器: 我有访问令牌,申请开放资源 资源服务器-->>-第三方应用: 同意开放资源

这个时序图里面涉及到了OAuth2中几个关键术语,我们通过前面那个具体的上下文语境来解释其含义,这对理解后续几种认证流程十分重要:

  • 第三方应用(Third-Party Application):需要得到授权访问我资源的那个应用,即“Travis-CI”
  • 授权服务器(Authorization Server):能够根据我的意愿提供授权(授权之前肯定已经进行了必要的认证过程,但这在技术上与授权可以没有直接关系)的服务,即“GitHub”
  • 资源服务器(Resource Server):能够提供第三方应用所需资源的服务(它与认证服务可以是相同的服务器,也可以是不同的服务器),即“代码仓库”
  • 资源所有者(Resource Owner): 拥有授权权限的人,这里即是“我”
  • 操作代理(User Agent):指用户用来访问服务器的工具,对于指代人类的“用户”来说这个通常就是浏览器,但在微服务中一个服务经常会作为另一个服务的"用户",此时指的可能就是HttpClient、RPCClient或者其他访问途径。

看来“用令牌代替密码”确实是解决问题的好方法,但这最多只能算个思路,距离执行步骤还是不够具体的,时序图中的“要求/同意授权”、“要求/同意发放令牌”、“要求/同意开放资源”几个服务请求、响应该如何设计,这就是执行步骤的关键了。对此,OAuth2一共提出了四种不同的授权方式(这就是我说OAuth2复杂繁琐的原因,摊手),分别为:

  • 授权码模式(Authorization Code)
  • 简化模式(Implicit)
  • 密码模式(Resource Owner Password Credentials)
  • 客户端模式(Client Credentials)

授权码模式

授权码模式是四种模式中最严谨(繁琐)的,它考虑到了几乎所有敏感信息泄漏的预防和后果。具体步骤的时序如下:

sequenceDiagram 资源所有者 ->> 操作代理: 通过操作代理访问应用 操作代理 ->> 第三方应用: 遇到需要使用的资源 第三方应用 ->> 授权服务器: 转向授权服务器的授权页面 资源所有者 ->>+ 授权服务器: 认证身份,同意授权 授权服务器 -->>- 操作代理: 返回第三方应用的回调地址,附带授权码 操作代理 ->> 第三方应用: 转向回调地址 第三方应用 ->>+ 授权服务器: 将授权码发回给授权服务器,换取访问令牌 授权服务器 -->>- 第三方应用: 给予访问令牌 opt 资源访问过程 第三方应用 ->>+ 资源服务器: 提供访问令牌 资源服务器 -->>- 第三方应用: 提供返回资源 第三方应用 -->> 资源所有者: 返回对资源的处理给用户 end

在开始完成整个授权过程以前,第三方应用先要到授权服务器上进行注册,所谓注册,是指向认证服务器提供一个域名地址,从授权服务器中获取ClientID和ClientSecret,然后便可以开始如下授权过程:

  1. 第三方应用将资源所有者(用户)导向授权服务器的授权页面,并向授权服务器提供ClientID及同意授权后的回调URI,这是一次客户端页面转向。
  2. 授权服务器根据ClientID确认第三方应用的身份,用户在授权服务器中决定是否同意向该身份的应用进行授权(认证的过程在此之前应该已经完成)。
  3. 如果用户同意授权,授权服务器将转向地第三方应用在第1步调用中提供的回调地址URI,并附带上一个授权码和获取令牌的地址作为参数,这也是一次客户端页面转向。
  4. 第三方应用通过回调地址收到授权码,然后将授权码与自己的ClientSecret一起作为参数,通过服务端向授权服务器提供的获取令牌的服务地址发起请求,换取令牌。该服务端应与注册时提供的域名一直。
  5. 授权服务器核对授权码和ClientSecret,确认无误后,向第三方应用授予令牌。令牌可以是一个或者两个,其中必定要有的是访问令牌(Access Token),可选的是刷新令牌(Refresh Token)。访问令牌用于到资源服务器获取资源,有效期较短,刷新令牌用于在访问令牌失效后重新获取,有效期较长。
  6. 资源服务器根据访问令牌所允许的权限,向第三方应用提供资源。

这个过程设计,已经考虑到了几乎所有合理的意外情况,举例几个容易想到的:

  • 会不会有其他应用冒充第三方应用骗取授权?
    ClientID代表一个第三方应用的“用户名”,这个是可以完全公开的。但ClientSecret应当只有应用自己才知道,这个代表了第三方应用的“密码”。在第5步发放令牌时,调用者必须能够提供ClientSecret才能成功完成。只要第三方应用妥善保管好ClientSecret,就没有人能够冒充它。
  • 为什么要先发放授权码,再用授权码换令牌?
    这是因为客户端转向(通常就是一次HTTP 302重定向)对于用户是可见的,换而言之,授权码完全可能会暴露给用户(以及用户机器上的其他程序),但由于用户并没有ClientSecret,光有授权码也是无法换取到令牌的,所以避免了令牌在传输转向过程中泄漏的风险。
  • 为什么要设计一个时限较长的刷新令牌和时限较短的访问令牌?不能直接把访问令牌的时间调长吗?
    这是为了缓解OAuth2在实际应用中的一个主要缺陷,通常访问令牌一旦发放,除非超过了令牌中的有效期,否则很难(需要付出较大代价)有其他方式让它失效,所以访问令牌的时效性一般设计的比较短(譬如几个小时),如果还需要继续用,那就定期用刷新令牌去更新,授权服务器就可以在更新过程中决定是否还要继续给予授权。至于为什么说很难让它失效,我们将放到下一节“凭证”中解释这一点。

尽管授权码模式是严谨的,但是它并不够好用,这不仅仅体现在它那繁复的调用过程上,还体现在它对第三方应用提出了一个具体的要求:必须有服务端(因为第4步要发起服务端转向,而且服务端的地址必须与注册时提供的回调URI在同一个域内)。不要觉得要求一个系统要有服务端是天经地义理所当然的事情,你现在阅读文章的这个网站就没有任何服务端的支持,里面使用到了GitHub Issue作为每篇文章的留言板,它对GitHub来说照样是第三方应用,需要OAuth2授权来解决。除浏览器外,现在越来越普遍的是移动或桌面端的Client-Side Web Applications,譬如现在大量的基于Cordova、Electron、Node-Webkit.js的PWA应用。所以在此需求里,引出了OAuth2的第二种授权模式:隐式授权。

隐式授权

隐式授权省略掉了通过授权码换取令牌的步骤,整个授权过程都不需要服务端支持,一步到位。其代价是在隐式授权中,授权服务器不会再去验证第三方应用的身份(因为没有服务器了,ClientSecret没有人保管,就没有意义了。但其实还是会限制第三方应用的回调URI地址必须与注册时提供的域名一致,有可能被DNS污染之类的攻击所攻破,但仍算是尽人事努力一下);也不能避免令牌暴露给资源所有者(以及用户机器上可能意图不轨的其他程序、HTTP的中间人攻击等)了。隐私授权的调用时序如下图(从此之后的授权模式,时序中我就不画资源访问部分的内容了,就是前面opt框中的那一部分,以便更聚焦重点)所示:

sequenceDiagram 资源所有者 ->> 操作代理: 通过操作代理访问应用 操作代理 ->> 第三方应用: 遇到需要使用的资源 第三方应用 ->> 授权服务器: 转向授权服务器的授权页面 资源所有者 ->> 授权服务器: 认证身份,同意授权 授权服务器 -->> 操作代理: 返回第三方应用的回调地址,通过Fragment附带访问令牌 操作代理 ->> 第三方应用: 转向回调地址,通过脚本提取出Fragment中的令牌

在以上过程设计中,与授权码模式模式的显著区别是授权服务器在得到用户授权后,直接返回了访问令牌,这显然降低了安全性,但OAuth2仍然努力尽可能地做到相对安全,譬如在前面提到的隐私授权中,尽管不需要用到服务端,但仍然需要在注册时提供回调域名,此时会要求该域名与接受令牌的域名处于同一个域内。此外,在隐私模式中明确禁止发放刷新令牌。

还有一点,在RFC 6749对隐式授权的描述中,特别强调了令牌是“通过Fragment带回”的。部分对超文本协议没有了解的读者,可能不知道Fragment是个什么东西?

额外知识:Fragment

In computer hypertext, a fragment identifier is a string of characters that refers to a resource that is subordinate to another, primary resource. The primary resource is identified by a Uniform Resource Identifier (URI), and the fragment identifier points to the subordinate resource.

——URI Fragment,Wikipedia

看了这段英文定义还是觉得概念不好的话,我简单告诉你,Fragment就是地址中"#"号后面的部分,譬如这个地址:

http://bookstore.icyfenix.cn/#/detail/1

后面的“/detail/1”便是Fragment,这个语法是在RFC 3986中定义的标准,规范中解释了这是用于客户端定位的URI从属资源,譬如HTML中就可以使用Fragment来做文档内的跳转(你现在可以点击一下这篇文章左边菜单中的几个子标题,看看浏览器地址的变化)而不会发起服务端请求。此外,如果浏览器对一个带有Fragment的地址发出Ajax请求,那Fragment是不会跟随请求被发送到服务端的,只能在客户端通过Script脚本来读取。所以隐式授权巧妙地利用这个特性,尽最大努力地避免了令牌从操作代理到第三方服务之间的链路存在被攻击的可能性,而被泄漏出去。而认证服务器到到操作代理之间的这一段链路的安全,则可以通过TLS(即HTTPS)来保证没有中间没有受到攻击的,我们可以要求认证服务器都是基于HTTPS的,但无法要求第三方应用都是基于HTTPS。

密码模式

前面所说的授权码模式和隐私模式属于纯粹的授权模式,它们与认证没有直接的联系,如何认证用户的真实身份这是与进行授权互相独立的过程。但在密码模式里,认证和授权就被整合成了同一个过程了。

密码模式原本的设计意图是仅限于用户对第三方应用是高度可信任的场景中使用,因为用户需要把密码明文提供给第三方应用,第三方以此向授权服务器获取令牌,一种可能合理的应用场景是操作系统作为第三方应用向授权服务器申请资源,用户把密码提供给操作系统有可能的。除此之外,这种高度可信的第三方本应该是较罕见的,但在分布式系统里,“第三方应用”完全可以看作是逻辑上与授权服务器同属一个系统,物理上独立于授权服务器部署的其他服务节点。譬如微服务集群中,负责用户身份认证的服务与授权服务很可能都是由同一个服务商所提供的,名义上是第三方,其实是同一个系统,这两者之间自然可以存在可信任的关系。

单体版本、微服务版本的Fenix's Bookstore都直接采用了密码模式将认证和授权统一到一个过程当中。你并不会担心前端的Frontend工程或者后端的Account等工程在接收到用户名、密码后,会通过这些敏感信息,向授权服务器申请权限进行恶意的资源访问,因为这些代码虽然在OAuth2里被视为第三方应用的角色,但它们事实上是Fenix's Bookstore这个系统的一部分。理解了密码模式的用途,它的调用时序就很简单了,如下图所示:

sequenceDiagram 资源所有者 ->> 第三方应用: 提供密码凭证 第三方应用 ->> 授权服务器: 发送用户的密码凭证 授权服务器 -->> 第三方应用: 发放访问令牌和刷新令牌

密码模式下“如何保障安全”的职责无法由OAuth2的过程设计来承担,应是由用户和第三方应用来自行保障,尽管OAuth2在规范中强调到“此模式下,第三方应用不得保存用户的密码”,但这并没有任何的约束力。

客户端模式

客户端模式是四种模式中最简单的,它只涉及到两个主体,第三方应用和授权服务器。如果严谨一点,现在说“第三方应用”其实已经不合适了,因为这里已经没有了“第二方”的存在,资源所有者、操作代理都是不存在的。甚至于叫“授权”都不太恰当,资源所有者都没有了,自然也不会有谁授予谁权限的过程。

客户端模式是指第三方应用(行文一致考虑,还是继续叫第三方应用)以自己的名义,向授权服务器申请资源许可。这通常用在一些管理或者自动处理形场景之中。举个例子,譬如我开了一家网上书店Fenix's Bookstore,因为小本经营,不像京东那样全国多个仓库可以调货,因此必须保证只要客户成功购买,书店就必须有货可发,不能超卖。但经常有顾客下了订单又拖着不付款,导致部分货物处于冻结状态。所以Fenix's Bookstore中有一个订单清理的定时服务,自动清理掉超过两分钟的未付款的订单。这件用户里,订单肯定属于用户自己的资源,如果把订单清理服务看作一个独立的第三方应用的话,他就不可能向用户去申请授权,而应该直接以自己的名义向授权服务器申请一个能清理所有用户订单的授权。客户端模式的时序如下图所示:

sequenceDiagram 应用 ->>+ 授权服务器: 申请授权 授权服务器 -->>- 应用: 发放访问令牌

后面在微服务的“可靠通讯”中会讲到,并不提倡各个微服务之间有默认的信任关系,所以服务之间调用也需要先认证授权。此时客户端模式也是一种常用的服务间认证授权的解决方案。Spring Cloud版本的Fenix's Bookstore是采用这种方案来保证微服务之间的合法调用的,Istio版本的Fenix's Bookstore则启用了双向mTLS通讯,使用客户端证书保障安全,感兴趣的读者可以对比一下这两种方式差异优劣。

OAuth2中还有一种与客户端模式类似的授权模式,在RFC 8628中定义为“设备码模式(Device Code)”,这里顺便简单提一下。设备码模式用于在无输入的情况下区分设备是否允许,典型的应用便是手机锁网解锁(锁网在国内较少,但在国外很常见)或者激活(譬如某游戏机注册到某个游戏平台)的过程。时序如下图所示:

sequenceDiagram 设备 ->> 授权服务器: 要求授权 授权服务器 -->> 设备: 提供验证URI和用户码 loop 循环直至超时 设备 ->> 授权服务器: 申请令牌 end

进行验证时,设备需要从授权服务器获取一个URI地址和一个用户码,然后需要用户手动或设备自动地到验证URI中输入用户码。在这个过程中,设备会一直循环,尝试去获取令牌,直到拿到令牌或者用户码过期为止。

Kudos to Star
总字数: 7,811 字  最后更新: 9/1/2020, 12:06:59 AM