我们平常所谈论的软件架构安全,主要包括(但不限于)以下这些问题的具体解决方案:
- 认证(Authentication):系统如何正确分辨出操作用户的真实身份
- 授权(Authorization):系统如何控制一个用户该看到哪些数据、能操作哪些功能?
- 凭证(Credentials):系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
- 保密(Confidentiality):系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
- 传输(Transport Security):系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?
- 验证(Verification):系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险?
而其中,认证(Authentication)、授权(Authorization)和凭证(Credentials)这三项可以说是一个系统中最基础的安全设计。
认证 #
认证主要解决的是“你是谁”的问题
HTTP 认证 #
RFC 7235 中定义了 HTTP 协议的通用认证框架:所有支持 HTTP 协议的服务器,当未授权的用户意图访问服务端保护区域的资源时,应返回 401 Unauthorized
的状态码,同时要在响应报文头里,附带以下两个分别代表网页认证和代理认证的 Header 之一,告知客户端应该采取哪种方式,产生能代表访问者身份的凭证信息:
WWW-Authenticate: <认证方案> realm=<保护区域的描述信息>
Proxy-Authenticate: <认证方案> realm=<保护区域的描述信息>
在接收到该响应后,客户端根据上面指定的认证方案,在请求资源的报文头中加入身份凭证信息,服务端核实通过后才会允许该请求正常返回,否则将返回 403 Forbidden
。其中,请求报文头要包含以下 Header 项之一:
Authorization: <认证方案> <凭证内容>
Proxy-Authorization: <认证方案> <凭证内容>
其中 HTTP Basic 认证是一种以演示为目的的认证方案,在一些不要求安全性的场合也有实际应用,比如家里的路由器登录。Basic 认证产生用户身份凭证的方法是让用户输入用户名和密码,经过 Base64 编码“加密”后作为身份凭证。
除 Basic 认证外,IETF 还定义了很多种可用于实际生产环境的认证方案,比如:
-
Digest: HTTP 摘要认证,可以把它看作是 Basic 认证的改良版本,针对 Base64 明文发送的风险,Digest 认证把用户名和密码加盐(一个被称为 Nonce 的变化值作为盐值)后,再通过 MD5/SHA 等哈希算法取摘要发送出去。这种认证方式依然是不安全的,无论客户端使用何种加密算法加密,无论是否采用了 Nonce 这样的动态盐值去抵御重放和冒认,当遇到中间人攻击时,依然存在显著的安全风险。
-
Bearer: 基于 OAuth 2.0 规范来完成认证,OAuth 2.0 是一个同时涉及到认证与授权的协议。
-
HOBA:HTTP Origin-Bound Authentication,一种基于自签名证书的认证方案。基于数字证书的信任关系主要有两类模型,一类是采用 CA(Certification Authority)层次结构的模型,由 CA 中心签发证书;另一种是以 IETF 的 Token Binding 协议为基础的 OBC(Origin Bound Certificates)自签名证书模型。
在 HTTP 认证框架中,认证方案是允许自行扩展的,因此很多厂商也扩展了自己的认证方案。
基于通讯内容:Web 认证 #
以 HTTP 协议为基础的认证框架,也只能面向传输协议而不是具体传输内容来设计。而对于依靠内容而不是传输协议来实现的认证方式,被称为 “Web 认证”,也被称为“表单认证”(Form Authentication)。
表单认证与 HTTP 认证分别有不同的关注点,可以结合使用
2019 年 3 月,Web 内容认证的标准 WebAuthn 被起草提出,彻底抛弃了传统的密码登录方式。
- 用户进入系统的注册页面,这个页面的格式、内容和用户注册时需要填写的信息,都不包含在 WebAuthn 标准的定义范围内。
- 当用户填写完信息,点击“提交注册信息”的按钮后,服务端先暂存用户提交的数据,生成一个随机字符串(规范中称为 Challenge)和用户的 UserID(在规范中称作凭证 ID),返回给客户端。
- 客户端的 WebAuthn API 接收到 Challenge 和 UserID,把这些信息发送给验证器(Authenticator),这个验证器你可以理解为用户设备上 TouchBar、FaceID、实体密钥等认证设备的统一接口。
- 验证器提示用户进行验证,如果你的机器支持多种认证设备,还会提示用户选择一个想要使用的设备。验证的结果是生成一个密钥对(公钥和私钥),验证器自己存储好私钥、用户信息以及当前的域名。然后使用私钥对 Challenge 进行签名,并将签名结果、UserID 和公钥一起返回给客户端。
- 浏览器将验证器返回的结果转发给服务器。
- 服务器核验信息,检查 UserID 与之前发送的是否一致,并对比用公钥解密后得到的结果与之前发送的 Challenge 是否一致,一致即表明注册通过,服务端存储该 UserID 对应的公钥。
WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案
授权 #
授权要解决“你能干什么”的问题。“授权”这个概念通常伴随着“认证”“审计”“账号”一同出现,被合称为 AAAA(Authentication、Authorization、Audit、Account)。
可靠性
对于单一系统来说,授权的过程是比较容易做到可控的。而在涉及多方的系统中,授权过程则是一个比较困难,但必须要严肃对待的问题,需要保证敏感数据不泄漏。现在,常用的多方授权协议主要有 OAuth 2.0 和 SAML 2.0(二者涵盖的功能并不是直接对等的)
结果可控
授权的结果是用于对程序功能或者资源的访问控制(Access Control)。现在,已形成理论体系的权限控制模型有很多,比如自主访问控制(Discretionary Access Control,DAC)、强制访问控制(Mandatory Access Control,MAC)、基于属性的访问控制(Attribute-Based Access Control,ABAC),还有最为常用的基于角色的访问控制(Role-Based Access Control,RBAC)。
OAuth 2.0 #
OAuth 2.0 解决的是第三方服务中涉及的安全授权问题,是面向于解决第三方应用(Third-Party Application)的认证授权协议。一个第三方应用,如 CloudIDE 需要得到用户的授权,来访问 Github 的代码仓库信息,如果直接将账号密码告诉 CloudIDE,就会有密码泄漏、访问范围不可控、授权无法回收等情况。这些就是 OAuth 2.0 所要解决的问题
OAuth 2.0 中的一些概念:
- 第三方应用(Third-Party Application):需要得到授权访问用户资源的那个应用,即此场景中的 “CloudIDE”。
- 授权服务器(Authorization Server):能够根据用户的意愿提供授权(授权之前肯定已经进行了必要的认证过程,但它与授权可以没有直接关系)的服务器,即此场景中的“GitHub”。
- 资源服务器(Resource Server):能够提供第三方应用所需资源的服务器,它与认证服务可以是相同的服务器,也可以是不同的服务器,即此场景中的“用户的代码仓库”。
- 资源所有者(Resource Owner): 拥有授权权限的人,即此场景中的“我”。
- 操作代理(User Agent):指用户用来访问服务器的工具,对于人类用户来说,这个通常是指浏览器。但在微服务中,一个服务经常会作为另一个服务的用户,此时指的可能就是 HttpClient、RPCClient 或者其他访问途径。
OAuth 2.0 有四种授权模式:
- 授权码模式(Authorization Code)
- 简化模式(Implicit)
- 密码模式(Resource Owner Password Credentials)
- 客户端模式(Client Credentials)
授权码模式 #
在授权认证前,CloudIDE 要先到授权服务器上进行注册,即,CloudIDE 向认证服务器提供一个域名地址,然后从授权服务器中获取 ClientID 和 ClientSecret
以下为授权流程:
-
第三方应用将资源所有者(用户)导向授权服务器的授权页面,并向授权服务器提供 ClientID 及用户同意授权后的回调 URI,这是第一次客户端页面转向。
-
授权服务器根据 ClientID 确认第三方应用的身份,用户在授权服务器中决定是否同意向该身份的应用进行授权。注意,用户认证的过程未定义在此步骤中,在此之前就应该已经完成。
-
如果用户同意授权,授权服务器将转向第三方应用在第 1 步调用中提供的回调 URI,并附带上一个授权码和获取令牌的地址作为参数,这是第二次客户端页面转向。
-
第三方应用通过回调地址收到授权码,然后将授权码与自己的 ClientSecret 一起作为参数,通过服务器向授权服务器提供的获取令牌的服务地址发起请求,换取令牌。该服务器的地址应该与注册时提供的域名处于同一个域中。
-
授权服务器核对授权码和 ClientSecret,确认无误后,向第三方应用授予令牌。令牌可以是一个或者两个,其中必定要有的是访问令牌(Access Token),可选的是刷新令牌(Refresh Token)。访问令牌用于到资源服务器获取资源,有效期较短,刷新令牌用于在访问令牌失效后重新获取,有效期较长。
-
资源服务器根据访问令牌所允许的权限,向第三方应用提供资源。
问题:
会不会有其他应用冒充第三方应用骗取授权:
这是因为客户端转向(通常就是一次 HTTP 302 重定向)对于用户是可见的,授权码可能会暴露给用户以及用户机器上的其他程序,但由于用户并没有 ClientSecret,光有授权码也无法换取到令牌,所以就避免了令牌在传输转向过程中被泄漏的风险。
为什么要先发放授权码,再用授权码换令牌?
通常情况下,访问令牌一旦发放,除非超过了令牌中的有效期,否则很难有其他方式让它失效。所以访问令牌的时效性一般会设计得比较短,比如几个小时,如果还需要继续用,那就定期用刷新令牌去更新,授权服务器可以在更新过程中决定是否还要继续给予授权。
授权码模式的认证方式实际上很繁琐,而且要求第三方应用有自己的后端服务器,以保存 ClientSecret,以及第 4 步中发起服务端转向,而且要求服务端的地址必须与注册时提供的地址在同一个域内。
隐式授权 #
隐式授权省略掉了通过授权码换取令牌的步骤,整个授权过程都不需要三方应用服务端的支持。授权服务器不会再去验证第三方应用的身份。
和授权码模式一样,限制第三方应用的回调 URI 地址必须与注册时提供的域名一致(可被 DNS 污染攻击)。但隐式授权也不能避免令牌暴露给资源所有者,不能避免用户机器上可能意图不轨的其他程序、HTTP 的中间人攻击等风险。
隐式模式与授权码模式的显著区别是授权服务器在得到用户授权后,直接返回了访问令牌。而且在隐式模式中也明确禁止发放刷新令牌。并且特别强调了令牌必须是“通过 Fragment 带回”的
http://bookstore.icyfenix.cn/#/detail/1
后面的 /detail/1
。用于客户端定位的 URI 从属资源,比如在 HTML 中,就可以使用 Fragment 来做文档内的跳转而不会发起服务端请求。而且规定浏览器中对一个带有 Fragment 的地址发出 Ajax 请求,Fragment 不会跟随请求被发送到服务端的,只能在客户端通过 Script 脚本来读取。
一般这种认证模式认证服务器必须都是启用 HTTPS,防止中间人攻击
密码模式 #
在密码模式里,认证和授权就被整合成了同一个过程。这种模式一般仅限于在用户对第三方应用是高度可信任的场景中使用。用户需要把密码明文提供给第三方应用,第三方以此向授权服务器获取令牌。
在该模式下,“如何保障安全”的职责无法由 OAuth 2.0 来承担,只能由用户和第三方应用来自行保障,尽管 OAuth 2.0 在规范中强调到“此模式下,第三方应用不得保存用户的密码”,但这并没有任何技术上的约束力。
客户端模式 #
是四种模式中最简单的,它只涉及到两个主体:第三方应用和授权服务器。
这种模式通常用于管理操作或者自动处理类型的场景中。它也是一种常用的同一系统服务间认证授权的解决方案。
此外,在 OAuth 2.0 中呢,还有一种与客户端模式类似的授权模式,在RFC 8628中定义为“设备码模式”(Device Code)。用于在无输入的情况下区分设备是否被许可使用,如设备激活(比如某游戏机注册到某个游戏平台)的过程。设备需要从授权服务器获取一个 URI 地址和一个用户码,然后需要用户手动或设备自动地到验证 URI 中输入用户码。
RBAC #
授权的结果是用于对程序功能或者资源的访问控制(Access Control),而 RBAC(基于角色的访问控制,Role-Based Access Control)是一种最为常用的权限控制模型。
基础概念 #
所有的访问控制模型,实质上都是在解决同一个问题:谁(User)拥有什么权限(Authority)去操作(Operation)哪些资源(Resource)
RBAC 模型在业界有很多种说法,其中,最具系统性且得到了普遍认可的说法,是美国乔治梅森(George Mason)大学信息安全技术实验室提出的 RBAC96 模型。
RBAC 将权限从用户身上剥离,改为绑定到“角色”(Role)上,“权限控制”这项工作,就可以具体化成针对“角色拥有操作哪些资源的许可”这个逻辑表达式的值是否为真的求解过程。
许可(Permission)其实是抽象权限的具象化体现。权限在 RBAC 系统中的含义是“允许何种操作作用于哪些资源之上”,这句话的具体实例即为“许可”。
“给论文点击通过按钮”就是一种许可 (Permission),它是“审核论文”这项权限的具象化体现。
概念间关系 #
RBAC 模型中,角色拥有许可的数量,是根据完成该角色工作职责所需的最小权限所赋予的。
如操作系统权限管理中的用户组,有不同角色划分:管理员(Administrator)、系统用户(System)、普通用户(Users)、来宾用户(Guests)等,分配其各自的权限。而当用户的职责发生变化时,在系统中就体现为它所隶属的角色被改变。比如将“普通用户角色”改变为“管理员角色”,就可以迅速让该用户具备管理员的多个细分权限,降低权限分配错误的风险。
RBAC-1 模型的角色权限继承关系
RBAC 还允许定义不同角色之间的关联与约束关系,如不同的角色之间可以有继承性。如果要描述开发经理应该和开发人员一样具有代码提交的权限,可以把代码提交的权限赋予到开发人员的角色上,开发经理的角色从开发人员中派生即可。
RBAC-2 模型的角色职责分离关系
角色之间也可以具有互斥性,要求,当权限被赋予角色时、或角色被赋予用户时应该遵循的强制性职责分离规定。
角色的互斥约束可以限制同一用户,只能分配到一组互斥角色集合中至多一个角色。如不能让同一名员工既当会计,也当出纳,否则资金安全无法保证。
角色的基数约束可以限制某一个用户拥有的最大角色数目,如不能让同一名员工全部包揽产品、设计、开发、测试等工作。
RBAC 的访问控制 #
建立访问控制模型的基本目的就是为了管理垂直权限和水平权限。
垂直权限即功能权限,如前面提到的审稿编辑有通过审核的权限、开发经理有代码提交的权限、出纳有从账户提取资金的权限,这一类某个角色完成某项操作的许可
水平权限则是数据权限,它很难抽象与通用。如用户 A、B 都属于同一个角色,但它们各自在系统中产生的数据完全有可能是私有的,A 访问或删除了 B 的数据也照样属于越权。
在 Spring Security 的设计里,用户和角色都可以拥有权限,比如在它的 HttpSecurity 接口就同时有着 hasRole() 和 hasAuthority() 方法
凭证 #
Cookie-Session #
HTTP 协议是一种无状态的传输协议,也就是协议对事务处理没有上下文的记忆能力,每一个请求都是完全独立的。但是我们希望 HTTP 能有一种手段,让服务器至少有办法区分出发送请求的用户是谁。为了实现这个目的,RFC 6265 规范就定义了 HTTP 的状态管理机制,在 HTTP 协议中增加了 Set-Cookie
指令。
这个指令的含义是以键值对的方式向客户端发送一组信息,在此后一段时间内的每次 HTTP 请求中,这组信息会附带着名为 Cookie 的 Header 重新发回给服务端,以便服务器区分来自不同客户端的请求。
Set-Cookie
示例:
Set-Cookie: id=icyfenix; Expires=Wed, 21 Feb 2020 07:28:00 GMT; Secure; HttpOnly
客户端再对同一个域的请求中,就会自动附带有键值对信息
GET /index.html HTTP/2.0
Host: icyfenix.cn
Cookie: id=icyfenix
根据每次请求传到服务端的 Cookie,服务器就能分辨出请求来自于哪一个用户。Cookie 放置在请求头,不能携带太多内容,会有传输负担且不安全。
一般来说,系统会把状态信息保存在服务端,而在 Cookie 里只传输一个无字面意义的、不重复的字符串,通常习惯上是以 sessionid
或者 jsessionid
为名。然后,服务器拿这个字符串为 Key,在内存中开辟一块空间,以 Key/Entity 的结构,来存储每一个在线用户的上下文状态,再辅以一些超时自动清理之类的管理措施。这种管理机制即为 Session。
Cookie-Session 也就是最传统的,但在今天依然广泛应用于大量系统中的、由服务端与客户端联动来完成的状态管理机制。
Cookie-Session 方案一个优点是服务端有主动的状态管理能力,可以根据自己的意愿随时修改、清除任意的上下文信息,比如很轻易就能实现强制某用户下线这样的功能。
JWT #
Cookie-Session 将状态保存到了服务端,而 JWT 则是状态信息存储在客户端,每次随着请求发回服务器中去。
JWT(JSON Web Token)定义于 RFC 7519 标准之中,是目前广泛使用的一种令牌格式,尤其经常与 OAuth 2.0 配合应用于分布式的、涉及多方的应用系统中。
上图是 JWT 的格式,左边是 JWT 本体,右边的 JSON 结构,是 JWT 令牌中携带的信息。它最常见的使用方式是附在名为 Authorization 的 Header 发送给服务端,其前缀在RFC 6750中被规定为 Bearer。
令牌结构示意图中,右边的状态信息是对令牌使用 Base64URL 转码后得到的明文。JWT 只解决防篡改的问题,并不解决防泄露的问题,所以令牌默认是不加密的。
JWT 第一部分是令牌头,描述了令牌的类型(统一为 typ:JWT)以及令牌签名的算法,内容如下:
{
"alg": "HS256",
"typ": "JWT"
}
第二部分是负载(Payload),是令牌真正需要向服务端传递的信息,其负载部分是可以完全自定义的。示例:
{
"username": "icyfenix",
"authorities": [
"ROLE_USER",
"ROLE_ADMIN"
],
"scope": [
"ALL"
],
"exp": 1584948947,
"jti": "9d77586a-3f4f-4cbb-9924-fe2f77dfa33d",
"client_id": "bookstore_frontend"
}
JWT 在 RFC 7519 标准中,推荐(非强制约束)了七项声明名称(Claim Name),如果你在设计令牌时需要用到这些内容,我建议其字段名要与官方的保持一致:
- iss(Issuer):签发人。
- exp(Expiration Time):令牌过期时间。
- sub(Subject):主题。
- aud (Audience):令牌受众。
- nbf (Not Before):令牌生效时间。
- iat (Issued At):令牌签发时间。
- jti (JWT ID):令牌编号。
令牌的第三部分是签名(Signature),使用在对象头中公开的特定签名算法,通过特定的密钥(Secret,由服务器进行保密,不能公开)对前面两部分内容进行加密计算,产生签名值,确保负载中的信息是可信的、没有被篡改。算法如下:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)
JWT 默认的签名算法 HMAC SHA256 是一种带密钥的哈希摘要算法,加密与验证过程都只能由中心化的授权服务来提供,所以这种方式一般只适合于授权服务与应用服务处于同一个进程中的单体应用。
另外,在多方系统,或者是授权服务与资源服务分离的分布式应用当中,通常会采用非对称加密算法来进行签名。这时候,除了授权服务端持有的可以用于签名的私钥以外,还会对其他服务器公开一个公钥,公开方式一般遵循 JSON Web Key 规范。不过,这个公钥不能用来签名,但它能被其他服务用于验证签名是否由私钥所签发的。
一般 JWT 更容易遭受重放攻击,且令牌难以主动失效,除非添加黑名单机制。