即使只限定在“软件架构设计”这个语境下,系统安全仍然是一 个很大的话题。我们谈论的计算机系统安全,不仅仅是指“防御系统 被黑客攻击”这样狭隘的安全,还至少应包括(不限于)以下这些问 题的具体解决方案。
与安全相关的问题,一般不会直接创造价值,解决起来又烦琐复 杂,费时费力,很容易被开发人员忽略,但庆幸的是这些问题基本上 也都是与具体系统、具体业务无关的通用性问题,这意味着它们往往 会存在业界通行的、已被验证过是行之有效的解决方案,甚至已经形 成行业标准,不需要开发者自己从头去构思如何解决。
认证、授权和凭证可以说是一个系统中最基础的安全设计,哪怕 再简陋的信息系统,大概也不可能忽略“用户登录”功能。信息系统 为用户提供服务之前,总是希望先弄清楚“你是谁”(认证)、“你 能干什么”(授权)以及“你如何证明”(凭证)这三个基本问题。 然而,这三个基本问题又不像部分开发者认为的那样,只是一个“系 统登录”功能,仅仅是校验一下用户名、密码是否正确这么简单。账 户和权限作为一种必须最大限度保障安全和隐私,同时又要兼顾各个 系统模块甚至系统间共享访问的基础主数据,它的存储、管理与使用 都面临一系列复杂的问题。对于某些大规模的信息系统,账户和权限 的管理往往要由专门的基础设施来负责,譬如微软的活动目录 (Active Directory,AD)或者轻量目录访问协议(Lightweight Directory Access Protocol,LDAP),跨系统的共享使用甚至会用到 区块链技术。
世纪之交,Java迎来了Web的辉煌时代,互联网的迅速兴起促使 Java进入快速发展时期。这时候,基于HTML和JavaScript的超文本Web 应用迅速超过了“Java 2时代”之前的Java Applets应用,B/S系统对 最终用户认证的需求使得“安全认证”的重点逐渐从“代码级安全” 转为“用户级安全”,即你是否信任正在操作的用户。在1999年,随 J2EE 1.2[1]发布的Servlet 2.2中添加了一系列用于认证的API,主要 包括下列两部分内容:
一项发布超过20年的老旧技术,原本并没有什么专门提起的必要 性,笔者之所以引用这件事,是希望从它包含的两部分内容中引出一 个架构安全性的经验原则:以标准规范为指导、以标准接口去实现。 安全涉及的问题很麻烦,但解决方案已相当成熟,对于99%的系统来 说,在安全上不去做轮子,不去想发明创造,严格遵循标准,就是最 恰当的安全设计。 引用J2EE 1.2对安全的改进还有另一个原因,它内置的ClientCert、Basic、Digest和Form这四种认证方案都很有代表性,刚好分别 覆盖了通信信道、协议和内容层面的认证。而这三种层面的认证恰好 涵盖了主流的三种认证方式,具体含义和应用场景列举如下。
授权这个概念通常伴随着认证、审计、账号一同出现,并称为 AAAA(Authentication、Authorization、Audit、Account,也有一些 领域把Account解释为计费的意思)。授权行为在程序中的应用非常广 泛,给某个类或某个方法设置范围控制符(public、protected、 private、)在本质上也是一种授权(访问控制)行为。而 在安全领域中所说的授权就更具体一些,通常涉及以下两个相对独立 的问题。
所有的访问控制模型,实质上都是在解决同一个问题:“谁 (User)拥有什么权限(Authority)去操作(Operation)哪些资源 (Resource)。”
这个问题初看起来并不难,一种直观的解决方案就是在用户对象 上设定一些权限,当用户使用资源时,检查是否有对应的操作权限即 可。很多著名的安全框架,譬如Spring Security的访问控制本质上就 是这么做的。不过,这种把权限直接关联在用户身上的简单设计,在 复杂系统上确实会导致一些比较烦琐的问题。试想一下,如果某个系 统涉及成百上千的资源,又有成千上万的用户,若要为每个用户访问 每个资源都分配合适的权限,必定导致巨大的操作量和极高的出错概 率,这也正是RBAC所关注的问题之一。
RBAC模型在业界中有多种说法,其中以美国George Mason大学信 息安全技术实验室提出的RBAC96模型最具系统性,得到了普遍的认 可。为了避免对每一个用户设定权限,RBAC将权限从用户身上剥离, 改为绑定到“角色”(Role)上,将权限控制变为对“角色拥有操作 哪些资源的许可”这个逻辑表达式的值是否为真的求解过程。RBAC的 主要元素的关系可以图5-5来表示。

许可是抽象权限的具象化 体现,权限在RBAC系统中的含义是“允许何种操作作用于哪些资源之 上”,这句话的具体实例即为“许可”。提出许可这个概念的目的其 实与提出角色的目的是完全一致的,只是更为抽象。角色为的是解耦 用户与权限之间的多对多关系,而许可为的是解耦操作与资源之间的 多对多关系,譬如不同的数据都能够有增、删、改等操作,如果将数 据与操作搅和在一起也会面临配置膨胀问题。

从实现角度来看,Spring Security中的角色和权限的差异很小, 它们完全共享同一套存储结构,唯一的差别仅是角色会在存储时自动 带上“ROLE_”前缀罢了。但从使用角度来看,角色和权限的差异可以 很大,用户可以自行决定系统中许可是只能对应到角色身上,还是可 以让用户也拥有某些角色中没有的权限。这一点不符合RBAC的思想, 但笔者个人认同这是一种创新而非破坏,在Spring Security的文档上 说得很清楚:这取决于你自己如何使用。
OAuth 2是在RFC 6749中定义的国际标准,在RFC 6749正文的第一句就阐明了OAuth 2是面向解决第三方应用(ThirdParty Application)的认证授权协议。
如何获得 我的授权呢?一种最简单的方案是把我的用户账号和密码都告诉 Travis-CI,但这显然导致了以下这些问题。
OAuth 2给出了多种解决办法,这些办法的共同特征是以令牌 (Token)代替用户密码作为授权的凭证。有了令牌之后,哪怕令牌被 泄漏,也不会导致密码的泄漏;令牌上可以设定访问资源的范围以及 时效性;每个应用都持有独立的令牌,任何一个失效都不会波及其 他。这样上面提出的三个问题就都解决了。加令牌后的整个授权流程。

这个流程图里面涉及了OAuth 2中的几个关键术语,我们通过前面 那个具体的上下文语境来解释其含义,这对理解后续几种认证流程十 分重要。
“用令牌代替密码”确实是解决问题的好方法,但这充其量只能 算个思路,距离可实施的步骤还是不够具体的,流程图中的“要求/同 意授权”、“要求/同意发放令牌”、“要求/同意开放资源”的服务 请求、响应该如何设计,这就是执行步骤的关键了。对此,OAuth 2一 共提出了四种不同的授权方式(这也是OAuth 2复杂烦琐的主要原 因),分别为:
在前面介绍OAuth 2的内容中,每一种授权模式的最终目标都是拿 到访问令牌,但从未涉及过拿回来的令牌应该长什么样子。反而还挖 了一些坑没有填(为何说OAuth 2的一个主要缺陷是令牌难以主动失 效)。这节讨论的主角是令牌,同时,还会讨论如果不使用OAuth 2, 如何以最传统的方式完成认证、授权。
对“如何承载认证授权信息”这个问题的不同看法,代表了软件 架构对待共享状态信息的两种不同思路:状态应该维护在服务端,还 是在客户端之中?在分布式系统崛起以前,这个问题原本已有了较为 统一的结论,即以HTTP协议的Cookie-Session机制为代表的服务端状 态存储在分布式崛起前的三十年中都是主流的解决方案。不过,到了 最近十年,由于分布式系统中共享数据必然会受到CAP不兼容原理的打 击限制,迫使人们重新去审视之前已基本放弃掉的客户端状态存储, 这就让原本只在多方系统中采用的JWT令牌方案,在分布式系统中也有 了另一块用武之地。本节将围绕Cookie-Session和JWT之间的相同与不 同而展开。
大家知道HTTP协议是一种无状态的传输协议,无状态是指协议对 事务处理没有上下文的记忆能力,每一个请求都是完全独立的,但是 我们中肯定有许多人并没有意识到HTTP协议无状态的重要性。假如你 做了一个简单的网页,其中包含1个HTML、2个Script脚本、3个CSS, 还有10张图片,若要这个网页成功展示在用户屏幕前,需要完成16次 与服务端的交互来获取上述资源,由于网络传输等各种因素的影响, 服务器发送的顺序与客户端请求的先后并没有必然的联系,按照可能 出现的响应顺序,理论上最多会有P(16,16)=20922789888000种可能 性。试想一下,如果HTTP协议不是设计成无状态的,这16次请求每一 次都有依赖关联,先调用哪一个、先返回哪一个,都会对结果产生影 响的话,那协调工作会多么复杂。
可是,HTTP协议的无状态特性又有悖于我们最常见的网络应用场 景,典型就是认证授权,系统总得要获知用户身份才能提供合适的服 务,因此,我们也希望HTTP能有一种手段,让服务器至少能够区分出 发送请求的用户是谁。为了实现这个目的,RFC 6265规范定义了HTTP 的状态管理机制,在HTTP协议中增加了Set-Cookie指令,该指令的含 义是以键值对的方式向客户端发送一组信息,此信息将在此后一段时 间内的每次HTTP请求中,以名为Cookie的Header附带着重新发给服务 端,以便服务端区分来自不同客户端的请求。
一般来说,系统会把状态信息保存在服务端,在 Cookie里只传输一个无字面意义的、不重复的字符串,习惯上以 sessionid或者jsessionid为名,然后服务端会把这个字符串作为 Key,以Key/Entity的结构存储每一个在线用户的上下文状态,再辅以 一些超时自动清理之类的管理措施。这种服务端的状态管理机制就是 今天大家非常熟悉的Session,Cookie-Session也即最传统但今天依然 广泛应用于大量系统中的,由服务端与客户端联动来完成的状态管理 机制。
Session-Cookie在单节点的单体服务环境中是最合适的方案,但 当需要水平扩展服务能力,要部署集群时就比较麻烦了,由于Session 存储在服务器的内存中,当服务器水平拓展成多节点时,设计者必须 在以下三种方案中选择其一。
通过前面章节的内容,我们已经知道只要在分布式系统中共享信 息,CAP就不可兼得,所以分布式环境中的状态管理一定会受到CAP的 限制,无论怎样都不可能完美。但如果只是解决分布式下的认证授权 问题,并顺带解决少量状态的问题,就不一定只能依靠共享信息去实 现。这句话的言外之意是提醒读者,接下来的JWT令牌与CookieSession并不是完全对等的解决方案,JWT令牌只用来处理认证授权问 题,充其量只能携带少量非敏感的信息,是Cookie-Session在认证授 权问题上的替代品,而不能说JWT要比Cookie-Session更先进,更不可 能说JWT可以全面取代Cookie-Session机制。
JWT(JSON Web Token)定义于RFC 7519标准之中,是目前广泛使 用的一种令牌格式,尤其经常与OAuth 2配合应用于分布式的、涉及多 方的应用系统中。介绍JWT的具体构成之前,我们先来直观地看一下它 是什么样子的,如图5-13所示。
以上截图来自JWT官网(https://jwt.io),数据则是笔者随意编 的。右边的JSON结构是JWT令牌中携带的信息,左边的字符串呈现了 JWT令牌的本体。它最常见的使用方式是附在名为Authorization的 Header发送给服务端,前缀在RFC 6750中被规定为Bearer。如果你没 有忘记“认证方案”与“OAuth 2”的内容,那看到Authorization这 个Header与Bearer这个前缀时,便应意识到它是HTTP认证框架中的 OAuth 2认证方案。如下代码展示了一次采用JWT令牌的HTTP实际请 求:


右边的状态信息是对令牌使用Base64URL转码后得到 的明文,请特别注意是明文,JWT只解决篡改的问题,并不解决泄漏的 问题,因此令牌默认是不加密的。尽管你自己要加密也不难做到,接 收时自行解密即可,但这样做其实没有太大意义。从明文中可以看到JWT令牌是以JSON结构(毕竟名字就叫JSON Web Token)存储的,该结构总体上可划分为三个部分,每个部分间用点号 “.”分隔开。
第一部分是令牌头(Header)
第二部分是负载(Payload),这是令牌真正需要向服务端 传递的信息
第三部分是签名(Signature),签名的意思是:使用在对 象头中公开的特定签名算法,通过特定的密钥(由服务器进行保密, 不能公开)对前面两部分内容进行加密计算,以例子里使用的JWT默认 的HMAC SHA256算法为例,将通过以下公式产生签名值。
尽管大型系统中只使用JWT来维护上下文状态,服务端完全 不持有状态是不太现实的,不过将热点的服务单独抽离出来做成无状 态,仍是一种有效提升系统吞吐能力的架构技巧。但是,JWT也并非没 有缺点的完美方案,它存在以下几个经常被提及的缺点。
保密是加密和解密的统称,是指以某种特殊的算法改变原有的信 息数据,使得未授权的用户即使获得了已加密的信息,但因不知解密 的方法,或者知晓解密的算法但缺少解密所需的必要信息,仍然无法 了解数据的真实内容。
保密是有成本的,追求越高的安全等级,就要付出越多的工作量 与算力消耗。
1)以摘要代替明文:如果密码本身比较复杂,那一次简单的哈希 摘要至少可以保证即使传输过程中有信息泄漏,也不会被逆推出原信 息;即使密码在一个系统中泄漏了,也不至于威胁到其他系统的使 用。但这种处理不能防止弱密码被彩虹表攻击所破解。
2)先加盐值再做哈希是应对弱密码的常用方法:盐值可以为弱密 码建立一道防御屏障,一定程度上防御已有的彩虹表攻击,但不能阻 止加密结果被监听、窃取后,攻击者直接发送加密结果给服务端进行 冒认。
3)将盐值变为动态值能有效防止冒认:如果每次密码向服务端传 输时都掺入了动态的盐值,让每次加密的结果都不同,那即使传输给 服务端的加密结果被窃取了,也不能冒用来进行另一次调用。尽管在 双方通信均可能泄漏的前提下协商出只有通信双方才知道的保密信息 是完全可行的(后续5.5.3节会提到),但这样协商出盐值的过程将变 得极为复杂,而且每次协商只保护一次操作,也难以阻止对其他服务 的重放攻击。
4)给服务加入动态令牌,在网关或其他流量公共位置建立校验逻 辑,这样服务端在愿意付出集群中分发令牌信息等代价的前提下,可 以做到防止重放攻击,但是依然不能解决传输过程中被嗅探而泄漏信 息的问题。
5)启用HTTPS可以防御链路上的恶意嗅探,也以在通信层面解决 了重放攻击的问题。但是依然有因客户端被攻破产生伪造根证书的风 险、因服务端被攻破产生的证书泄漏而被中间人冒认的风险、因CRL更 新不及时或者OCSP Soft-fail产生吊销证书被冒用的风险,以及因TLS 的版本过低或密码学套件选用不当产生加密强度不足的风险。
6)为了抵御上述风险,保密强度还要进一步提升,譬如银行会使 用独立于客户端的存储证书的物理设备(俗称的U盾)来避免根证书被 客户端中的恶意程序窃取伪造;大型网站涉及账号、金钱等操作时, 会使用双重验证开辟一条独立于网络的信息通道(如手机验证码、电 子邮件)来显著提高冒认的难度;甚至一些关键企业(如国家电网) 或机构(如军事机构)会专门建设遍布全国各地的与公网物理隔离的 专用内部网络来保障通信安全。
关于客户端在用户登录、注册类场景里是否需要对密码进行加密 的问题一直存有争议。笔者的观点很明确:为了保证信息不被黑客窃 取而做客户端加密没有太大意义,对绝大多数的信息系统来说,启用 HTTPS可以说是唯一的实际可行的方案。但是,为了保证密码不在服务 端被滥用,在客户端就开始加密还是很有意义的。密码明文被写入数据库、被输出到日志中之类的事情也 屡见不鲜,做系统设计时就应该把明文密码这种东西当成是最烫手的 山芋来看待,越早消灭掉越好,将一个潜在的炸弹从客户端运到服务 端,对绝大多数系统来说都没有必要。
我们从JWT令牌的一小段“题外话”来引出现代密码学算法的三种 主要用途:摘要、加密与签名。JWT令牌携带信息的可信度源自于它是 被签过名的信息,是令牌签发者真实意图的体现,因此是不可篡改 的。然而,你是否了解签名具体做了什么?为什么有签名就能够让负 载中的信息变得不可篡改和不可抵赖呢?要解释数字签名(Digital Signature),必须先从密码学算法的另外两种基础应用“摘要”和 “加密”说起。
摘要也称为数字摘要(Digital Digest)或数字指纹(Digital Fingerprint)。JWT令牌中默认的签名信息是对令牌头、负载和密钥 三者通过令牌头中指定的哈希算法(HMAC SHA256)计算出来的摘要 值。
理想的哈希算法都具备两个特性。一是易变性,这是指算法的输 入端发生了任何一点细微变动,都会引发雪崩效应(Avalanche Effect),使得输出端的结果产生极大的变化。二是不可逆性,摘要的运算过程是单向的,不可能 从摘要的结果中逆向还原出输入值来。世间的信息有无穷多种,而摘 要的结果无论其位数是32、128、512位,甚至更多位,都是一个有限 的数字,因此输入数据与输出的摘要结果必然不是一一对应的关系。
由这两个特性可见,摘要的意义是在源信息不泄漏的前提下辨别 其真伪。易变性保证了可以从公开的特征上甄别出信息是否来自于源 信息,不可逆性保证了不会从公开的特征暴露出源信息,这与今天用 作身份甄别的指纹、面容和虹膜的生物特征是具有高度可比性的。
20世纪70年代中后期出现的非对称加密算法从根本上解决了密钥 分发的难题,它将密钥分成公钥和私钥。公钥可以完全公开,无须安 全传输的保证。私钥由用户自行保管,不参与任何通信传输。根据这 两个密钥加解密方式的不同,使得算法可以提供两种不同的功能。
当我们无法以“签名”的手段来达成信任时,就只能求助于其他 途径。不妨先想一想真实的世界中,我们是如何达成信任的,其实不 外乎以下两种。
回到网络世界中,我们并不能假设授权服务器和资源服务器是互 相认识的,所以通常不太会采用第一种方式,而第二种就是目前保证 公钥可信分发的标准,即公开密钥基础设施(Public Key Infrastructure,PKI)。咱们不必纠缠于PKI概念上的内容,只要知道里面定义的“数字证 书认证中心”相当于前面例子中“权威公证人”的角色,是负责发放 和管理数字证书的权威机构即可。
到这里出现了本节的主角之一:证书(Certificate)。证书是权 威CA中心对特定公钥信息的一种公证载体,也可以理解为权威CA对特 定公钥未被篡改的签名背书。由于客户的机器上已经预置了这些权威 CA中心本身的证书(称为CA证书或者根证书),所以我们能够在不依 靠网络的前提下,使用根证书里面的公钥信息对其所签发的证书中的 签名进行确认。到此,终于打破了鸡生蛋、蛋生鸡的循环,使得整套 数字签名体系有了坚实的逻辑基础
数据验证与程序的编码密切相关,很多开发者往往不把它归入安全范畴。然而,关注“你是谁”(认证)和“你能做什么”(授权)等问题是合理的安全实践,关注“你做得对不对”(验证)同样合理。数据验证不严谨导致的安全问题远多于其他安全攻击,而这些问题的风险有高有低。高风险的数据问题可能导致的损失不一定小于黑客攻击带来的损失。
数据验证是贯穿于开发中的每一个层次的常规工作。缺失的校验会影响数据质量,过度的校验不会使系统更加健壮,反而会制造垃圾代码,甚至带来副作用。下列是一个实际的例子,说明了没有统一校验策略所带来的问题:
前端提交用户数据 -> 控制器层发现邮箱为空 -> 安全层发现手机号格式错误 -> 服务层发现邮箱重复 -> 持久层发现签名字段超长无法插入数据库。这种情况不仅用户体验差,还会导致各层的异常处理混乱。
验证应在哪一层进行存在争议:
在Java中,推荐的做法是使用Bean Validation,将校验行为从分层中剥离出来,在Bean上做验证。这种方法的好处包括:
Bean Validation是Java的标准规范,执行频率高且可重复利用。对无业务逻辑的注解,如@NotEmpty、@Pattern等,重复执行不会产生成本。对于带业务逻辑的校验,如需要外部资源参与的校验,应让使用者判断是否触发。对于不同操作(如新增、修改、删除)需要不同校验规则时,可以使用分组校验来处理。
以下是一个使用Bean Validation的示例:
- public class Account extends BaseEntity {
- @NotEmpty(message = "用户不允许为空")
- private String username;
-
- @NotEmpty(message = "用户姓名不允许为空")
- private String name;
-
- private String avatar;
-
- @Pattern(regexp = "1\\d{10}", message = "手机号格式不正确")
- private String telephone;
-
- @Email(message = "邮箱格式不正确")
- private String email;
- }
每次执行校验时,注解都会被运行。复杂的业务校验可以通过自定义校验注解实现,如:
- @POST
- public Response createUser(@Valid @UniqueAccount Account user) {
- return CommonResponse.op(() -> service.createAccount(user));
- }
-
- @PUT
- @CacheEvict(key = "#user.username")
- public Response updateUser(@Valid @AuthenticatedAccount @NotConflictAccount Account user) {
- return CommonResponse.op(() -> service.updateAccount(user));
- }
这些自定义校验注解的含义如下:
通过这种方式,验证的灵活性和管理的集中性得到了保证,系统的健壮性和用户体验也得到了提升。