认证
认证(Authentication)
系统如何正确分辨出操作用户的真实身份?
- 通信信道上的认证:你和我建立通信连接之前,要先证明你是谁。在网络传输(Network)场景中的典型是基于 SSL/TLS 传输安全层的认证。
- 通信协议上的认证:你请求获取我的资源之前,要先证明你是谁。在互联网(Internet)场景中的典型是基于 HTTP 协议的认证。
- 通信内容上的认证:你使用我提供的服务之前,要先证明你是谁。在万维网(World Wide Web)场景中的典型是基于 Web 内容的认证。
HTTP 认证
IETF 在RFC 7235中定义了 HTTP 协议的通用认证框架,要求所有支持 HTTP 协议的服务器,在未授权的用户意图访问服务端保护区域的资源时,应返回 401 Unauthorized 的状态码,同时应在响应报文头里附带以下两个分别代表网页认证和代理认证的 Header 之一,告知客户端应该采取何种方式产生能代表访问者身份的凭证信息:
1 | WWW-Authenticate: <认证方案> realm=<保护区域的描述信息> |
接收到该响应后,客户端必须遵循服务端指定的认证方案,在请求资源的报文头中加入身份凭证信息,由服务端核实通过后才会允许该请求正常返回,否则将返回 403 Forbidden 错误。请求头报文应包含以下 Header 项之一:
1 | Authorization: <认证方案> <凭证内容> |
认证方案
HTTP Basic 认证是一种主要以演示为目的的认证方案,也应用于一些不要求安全性的场合,譬如家里的路由器登录等。Basic 认证产生用户身份凭证的方法是让用户输入用户名和密码,经过 Base64 编码“加密”后作为身份凭证。譬如请求资源GET /admin
后,浏览器会收到来自服务端的如下响应:
1 | 401 Unauthorized |
此时,浏览器必须询问最终用户,即弹出类似图 5-2 所示的 HTTP Basic 认证对话框,要求提供用户名和密码。
服务端接收到请求,解码后检查用户名和密码是否合法,如果合法就返回/admin
的资源,否则就返回 403 Forbidden 错误,
- Digest:RFC 7616,HTTP 摘要认证,可视为 Basic 认证的改良版本,针对 Base64 明文发送的风险,Digest 认证把用户名和密码加盐(一个被称为 Nonce 的变化值作为盐值)后再通过 MD5/SHA 等哈希算法取摘要发送出去。
- Bearer:RFC 6750,基于 OAuth 2 规范来完成认证,OAuth2 是一个同时涉及认证与授权的协议
- HOBA:RFC 7486 ,HOBA(HTTP Origin-Bound Authentication)是一种基于自签名证书的认证方案。基于数字证书的信任关系主要有两类模型:一类是采用 CA(Certification Authority)层次结构的模型,由 CA 中心签发证书;另一种是以 IETF 的 Token Binding 协议为基础的 OBC(Origin Bound Certificate)自签名证书模型。
授权
授权( Authorization)
系统如何控制一个用户该看到哪些数据、能操作哪些功能?
安全领域中所说的授权通常涉及以下两个相对独立的问题:
- 确保授权的过程可靠:对于单一系统来说,授权的过程是比较容易做到可控的,以前很多语境上提到授权,实质上讲的都是访问控制,理论上两者是应该分开的。而在涉及多方的系统中,授权过程则是一个比较困难却必须严肃对待的问题:如何既让第三方系统能够访问到所需的资源,又能保证其不泄露用户的敏感数据呢?常用的多方授权协议主要有 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
OAuth2 是面向于解决第三方应用(Third-Party Application)的认证授权协议。
- 密码泄漏:如果 Travis-CI 被黑客攻破,将导致我的 GitHub 的密码也同时被泄漏。
- 访问范围:Travis-CI 将有能力读取、修改、删除、更新我放在 GitHub 上的所有代码仓库,而我并不希望它能够修改删除文件。
- 授权回收:只有修改密码才能回收我授予给 Travis-CI 的权力,可是我在 GitHub 的密码只有一个,授权的应用除了 Travis-CI 之外却还有许多,修改了意味着所有别的第三方的应用程序会全部失效。
OAuth2 给出了多种解决办法,这些办法的共同特征是以令牌(Token)代替用户密码作为授权的凭证。有了令牌之后,哪怕令牌被泄漏,也不会导致密码的泄漏;令牌上可以设定访问资源的范围以及时效性;每个应用都持有独立的令牌,哪个失效都不会波及其他。
凭证
凭证(Credentials)
系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
Cookie-Session
HTTP 协议是一种无状态的传输协议,无状态是指协议对事务处理没有上下文的记忆能力,每一个请求都是完全独立的。
RFC 6265规范定义了 HTTP 的状态管理机制,在 HTTP 协议中增加了 Set-Cookie 指令,该指令的含义是以键值对的方式向客户端发送一组信息,此信息将在此后一段时间内的每次 HTTP 请求中,以名为 Cookie 的 Header 附带着重新发回给服务端,以便服务端区分来自不同客户端的请求。一个典型的 Set-Cookie 指令如下所示:
1 | Set-Cookie: id=icyfenix; Expires=Wed, 21 Feb 2020 07:28:00 GMT; Secure; HttpOnly |
收到该指令以后,客户端再对同一个域的请求中就会自动附带有键值对信息id=icyfenix
,譬如以下代码所示:
1 | GET /index.html HTTP/2.0 |
系统会把状态信息保存在服务端,在 Cookie 里只传输的是一个无字面意义的、不重复的字符串,习惯上以sessionid
或者jsessionid
为名,服务器拿这个字符串为 Key,在内存中开辟一块空间,以 Key/Entity 的结构存储每一个在线用户的上下文状态。
Cookie-Session 方案在“安全性”上其实是有一定先天优势的:
- 状态信息都存储于服务器,只要依靠客户端的同源策略和 HTTPS 的传输层安全,保证 Cookie 中的键值不被窃取而出现被冒认身份的情况,就能完全规避掉上下文信息在传输过程中被泄漏和篡改的风险。
- Cookie-Session 方案的另一大优点是服务端有主动的状态管理能力,可根据自己的意愿随时修改、清除任意上下文信息,譬如很轻易就能实现强制某用户下线的这样功能。
Session-Cookie 在单节点的单体服务环境中是最合适的方案,但当需要水平扩展服务能力,要部署集群时就开始面临麻烦了,由于 Session 存储在服务器的内存中,当服务器水平拓展成多节点时,设计者必须在以下三种方案中选择其一:
- 牺牲集群的一致性(Consistency),让均衡器采用亲和式的负载均衡算法,譬如根据用户 IP 或者 Session 来分配节点,每一个特定用户发出的所有请求都一直被分配到其中某一个节点来提供服务,每个节点都不重复地保存着一部分用户的状态,如果这个节点崩溃了,里面的用户状态便完全丢失。
- 牺牲集群的可用性(Availability),让各个节点之间采用复制式的 Session,每一个节点中的 Session 变动都会发送到组播地址的其他服务器上,这样某个节点崩溃了,不会中断都某个用户的服务,但 Session 之间组播复制的同步代价高昂,节点越多时,同步成本越高。
- 牺牲集群的分区容忍性(Partition Tolerance),让普通的服务节点中不再保留状态,将上下文集中放在一个所有服务节点都能访问到的数据节点中进行存储。此时的矛盾是数据节点就成为了单点,一旦数据节点损坏或出现网络分区,整个集群都不再能提供服务。
JWT
JWT(JSON Web Token)定义于RFC 7519标准之中,是目前广泛使用的一种令牌格式,尤其经常与 OAuth2 配合应用于分布式的、涉及多方的应用系统中。
右边的状态信息是对令牌使用 Base64URL 转码后得到的明文,请特别注意是明文,JWT 只解决防篡改的问题,并不解决防泄漏的问题,因此令牌默认是不加密的。
令牌头(Header)
1 | { |
它描述了令牌的类型(统一为 typ:JWT)以及令牌签名的算法,示例中 HS256 为 HMAC SHA256 算法的缩写。
负载(Payload)
这是令牌真正需要向服务端传递的信息。针对认证问题,负载至少应该包含能够告知服务端“这个用户是谁”的信息,针对授权问题,令牌至少应该包含能够告知服务端“这个用户拥有什么角色/权限”的信息。
JWT 的负载部分是可以完全自定义的,只是总容量不能太大,毕竟要受到 HTTP Header 大小的限制。一个 JWT 负载的例子如下所示:
1 | { |
而 JWT 在 RFC 7519 中推荐(非强制约束)了七项声明名称,如有需要用到这些内容,建议字段名与官方的保持一致:
- iss(Issuer):签发人。
- exp(Expiration Time):令牌过期时间。
- sub(Subject):主题。
- aud (Audience):令牌受众。
- nbf (Not Before):令牌生效时间。
- iat (Issued At):令牌签发时间。
- jti (JWT ID):令牌编号。
签名(Signature)
使用在对象头中公开的特定签名算法,通过特定的密钥(Secret,由服务器进行保密,不能公开)对前面两部分内容进行加密计算,以例子里使用的 JWT 默认的 HMAC SHA256 算法为例,将通过以下公式产生签名值:
1 | HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret) |
签名的意义在于确保负载中的信息是可信的、没有被篡改的,也没有在传输过程中丢失任何信息。因为被签名的内容哪怕发生了一个字节的变动,也会导致整个签名发生显著变化。此外,由于签名这件事情只能由认证授权服务器完成(只有它知道 Secret),任何人都无法在篡改后重新计算出合法的签名值,所以服务端才能够完全信任客户端传上来的 JWT 中的负载信息。
JWT 默认的签名算法 HMAC SHA256 是一种带密钥的哈希摘要算法,加密与验证过程均只能由中心化的授权服务来提供,所以这种方式一般只适合于授权服务与应用服务处于同一个进程中的单体应用。在多方系统或者授权服务与资源服务分离的分布式应用中,通常会采用非对称加密算法来进行签名,这时候除了授权服务端持有的可以用于签名的私钥外,还会对其他服务器公开一个公钥,公开方式一般遵循JSON Web Key 规范。公钥不能用来签名,但是能被其他服务用于验证签名是否由私钥所签发的。这样其他服务器也能不依赖授权服务器、无须远程通信即可独立判断 JWT 令牌中的信息的真伪。
JWT 令牌是多方系统中一种优秀的凭证载体,它不需要任何一个服务节点保留任何一点状态信息,同时,由于 JWT 本身可以携带少量信息,这十分有利于 RESTful API 的设计,能够较容易地做成无状态服务,但是,JWT 也并非没有缺点的完美方案,它存在着以下几个经常被提及的缺点:
- 令牌难以主动失效:JWT 令牌一旦签发,理论上就和认证服务器再没有什么瓜葛了,在到期之前就会始终有效,除非服务器部署额外的逻辑去处理失效问题,这对某些管理功能的实现是很不利的。譬如一种颇为常见的需求是:要求一个用户只能在一台设备上登录,在 B 设备登录后,之前已经登录过的 A 设备就应该自动退出。如果采用 JWT,就必须设计一个“黑名单”的额外的逻辑,用来把要主动失效的令牌集中存储起来,而无论这个黑名单是实现在 Session、Redis 或者数据库中,都会让服务退化成有状态服务,降低了 JWT 本身的价值,但黑名单在使用 JWT 时依然是很常见的做法,需要维护的黑名单一般是很小的状态量,许多场景中还是有存在价值的。
- 相对更容易遭受重放攻击:首先说明 Cookie-Session 也是有重放攻击问题的,只是因为 Session 中的数据控制在服务端手上,应对重放攻击会相对主动一些。要在 JWT 层面解决重放攻击需要付出比较大的代价,无论是加入全局序列号(HTTPS 协议的思路)、Nonce 字符串(HTTP Digest 验证的思路)、挑战应答码(当下网银动态令牌的思路)、还是缩短令牌有效期强制频繁刷新令牌,在真正应用起来时都很麻烦。真要处理重放攻击,建议的解决方案是在信道层次(譬如启用 HTTPS)上解决,而不提倡在服务层次(譬如在令牌或接口其他参数上增加额外逻辑)上解决。
- 只能携带相当有限的数据:HTTP 协议并没有强制约束 Header 的最大长度,但是,各种服务器、浏览器都会有自己的约束,譬如 Tomcat 就要求 Header 最大不超过 8KB,而在 Nginx 中则默认为 4KB,因此在令牌中存储过多的数据不仅耗费传输带宽,还有额外的出错风险。
- 必须考虑令牌在客户端如何存储:严谨地说,这个并不是 JWT 的问题而是系统设计的问题。如果授权之后,操作完关掉浏览器就结束了,那把令牌放到内存里面,压根不考虑持久化那是最理想的方案。但并不是谁都能忍受一个网站关闭之后下次就一定强制要重新登录的。这样的话,想想客户端该把令牌存放到哪里?Cookie?localStorage?Indexed DB?它们都有泄漏的可能,而令牌一旦泄漏,别人就可以冒充用户的身份做任何事情。
- 无状态也不总是好的:这个其实不也是 JWT 的问题。如果不能想像无状态会有什么不好的话,我给你提个需求:请基于无状态 JWT 的方案,做一个在线用户实时统计功能。
保密
保密(Confidentiality)
系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
保密是加密和解密的统称,是指以某种特殊的算法改变原有的信息数据,使得未授权的用户即使获得了已加密的信息,但因不知解密的方法,或者知晓解密的算法但缺少解密所需的必要信息,仍然无法了解数据的真实内容。
保密的强度
- 以摘要代替明文:如果密码本身比较复杂,那一次简单的哈希摘要至少可以保证即使传输过程中有信息泄漏,也不会被逆推出原信息;即使密码在一个系统中泄漏了,也不至于威胁到其他系统的使用,但这种处理不能防止弱密码被彩虹表攻击所破解。
- 先加盐值再做哈希是应对弱密码的常用方法:盐值可以替弱密码建立一道防御屏障,一定程度上防御已有的彩虹表攻击,但并不能阻止加密结果被监听、窃取后,攻击者直接发送加密结果给服务端进行冒认。
- 将盐值变为动态值能有效防止冒认:如果每次密码向服务端传输时都掺入了动态的盐值,让每次加密的结果都不同,那即使传输给服务端的加密结果被窃取了,也不能冒用来进行另一次调用。
- 给服务加入动态令牌,在网关或其他流量公共位置建立校验逻辑,服务端愿意付出在集群中分发令牌信息等代价的前提下,可以做到防止重放攻击,但是依然不能抵御传输过程中被嗅探而泄漏信息的问题。
- 启用 HTTPS 可以防御链路上的恶意嗅探,也能在通信层面解决了重放攻击的问题。但是依然有因客户端被攻破产生伪造根证书风险、有因服务端被攻破产生的证书泄漏而被中间人冒认的风险、有因CRL更新不及时或者OCSP Soft-fail 产生吊销证书被冒用的风险、有因 TLS 的版本过低或密码学套件选用不当产生加密强度不足的风险。
- 为了抵御上述风险,保密强度还要进一步提升,譬如银行会使用独立于客户端的存储证书的物理设备(俗称的 U 盾)来避免根证书被客户端中的恶意程序窃取伪造;大型网站涉及到账号、金钱等操作时,会使用双重验证开辟一条独立于网络的信息通道(如手机验证码、电子邮件)来显著提高冒认的难度;甚至一些关键企业(如国家电网)或机构(如军事机构)会专门建设遍布全国各地的与公网物理隔离的专用内部网络来保障通信安全。
客户端加密
为了保证密码不在服务端被滥用,在客户端就开始加密是很有意义的。
在传输链路必定是不安全的假设前提下,无论客户端做什么防御措施,中间人攻击通过劫持掉了客户端到服务端之间的某个节点,包括但不限于代理(通过 HTTP 代理返回赝品)、路由器(通过路由导向赝品)、DNS 服务(直接将你机器的 DNS 查询结果替换为赝品地址)等。
真正防御性的密码加密存储确实应该在服务端中进行,但这是为了防御服务端被攻破而批量泄漏密码的风险,并不是为了增加传输过程的安全。
密码存储和验证
一个普通安全强度的信息系统,密码如何从客户端传输到服务端,然后存储进数据库的全过程。“普通安全强度”是指在具有一定保密安全性的同时,避免消耗过多的运算资源,验证起来也相对便捷。对多数信息系统来说,只要配合一定的密码规则约束,譬如密码要求长度、特殊字符等,再配合 HTTPS 传输,已足防御大多数风险了。即使在用户采用了弱密码、客户端通信被监听、服务端被拖库、泄漏了存储的密文和盐值等问题同时发生,也能够最大限度避免用户明文密码被逆推出来。
用户在客户端注册,输入明文密码:
123456
。1
password = 123456
客户端对用户密码进行简单哈希摘要,可选的算法有 MD2/4/5、SHA1/256/512、BCrypt、PBKDF1/2,等等。为了突出“简单”的哈希摘要,这里笔者故意没有排除掉 MD 系这些已经有了高效碰撞手段的算法。
1
client_hash = MD5(password) // e10adc3949ba59abbe56e057f20f883e
为了防御彩虹表攻击应加盐处理,客户端加盐只取固定的字符串即可,如实在不安心,最多用伪动态的盐值(“伪动态”是指服务端不需要额外通信可以得到的信息,譬如由日期或用户名等自然变化的内容,加上固定字符串构成)。
1
client_hash = MD5(MD5(password) + salt) // SALT = $2a$10$o5L.dWYEjZjaejOmN3x4Qu
假设攻击者截获了客户端发出的信息,得到了摘要结果和采用的盐值,那攻击者就可以枚举遍历所有 8 位字符以内(“8 位”只是举个例子,反正就是指弱密码,你如果拿 1024 位随机字符当密码用,加不加盐,彩虹表都跟你没什么关系)的弱密码,然后对每个密码再加盐计算,就得到一个针对固定盐值的对照彩虹表。为了应对这种暴力破解,并不提倡在盐值上做动态化,更理想的方式是引入慢哈希函数来解决。
慢哈希函数是指这个函数执行时间是可以调节的哈希函数,通常是以控制调用次数来实现的。BCrypt 算法就是一种典型的慢哈希函数,它做哈希计算时接受盐值 Salt 和执行成本 Cost 两个参数(代码层面 Cost 一般是混入在 Salt 中,譬如上面例子中的 Salt 就是混入了 10 轮运算的盐值,10 轮的意思是 210次哈希,Cost 参数是放在指数上的,最大取值就 31)。如果我们控制 BCrypt 的执行时间大概是 0.1 秒完成一次哈希计算的话,按照 1 秒生成 10 个哈希值的速度,算完所有的 10 位大小写字母和数字组成的弱密码大概需要 P(62,10)/(3600×24×365)/0.1=1,237,204,169 年时间。
1
client_hash = BCrypt(MD5(password) + salt) // MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
只需防御被拖库后针对固定盐值的批量彩虹表攻击。具体做法是为每一个密码(指客户端传来的哈希值)产生一个随机的盐值。笔者建议采用“密码学安全伪随机数生成器”(Cryptographically Secure Pseudo-Random Number Generator,CSPRNG)来生成一个长度与哈希值长度相等的随机字符串。对于 Java 语言,从 Java SE 7 起提供了
java.security.SecureRandom
类,用于支持 CSPRNG 字符串生成。1
2
3SecureRandom random = new SecureRandom();
byte server_salt[] = new byte[36];
random.nextBytes(server_salt); // tq2pdxrblkbgp8vt8kbdpmzdh1w8bex将动态盐值混入客户端传来的哈希值再做一次哈希,产生出最终的密文,并和上一步随机生成的盐值一起写入到同一条数据库记录中。由于慢哈希算法占用大量处理器资源,笔者并不推荐在服务端中采用。不过,如果你阅读了 Fenix’s Bookstore 的源码,会发现这步依然采用了 Spring Security 5 中的
BcryptPasswordEncoder
,但是请注意它默认构造函数中的 Cost 参数值为-1,经转换后实际只进行了 210=1024 次计算,并不会对服务端造成太大的压力。此外,代码中并未显式传入 CSPRNG 生成的盐值,这是因为BCryptPasswordEncoder
本身就会自动调用 CSPRNG 产生盐值,并将该盐值输出在结果的前 32 位之中,因此也无须专门在数据库中设计存储盐值字段。这个过程以伪代码表示如下:1
2server_hash = SHA256(client_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
DB.save(server_hash, server_salt);以上加密存储的过程相对复杂,但是运算压力最大的过程(慢哈希)是在客户端完成的,对服务端压力很小,也不惧怕因网络通信被截获而导致明文密码泄漏。密码存储后,以后验证的过程与加密是类似的,步骤如下:
客户端,用户在登录页面中输入密码明文:
123456
,经过与注册相同的加密过程,向服务端传输加密后的结果。1
authentication_hash = MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
服务端,接受到客户端传输上来的哈希值,从数据库中取出登录用户对应的密文和盐值,采用相同的哈希算法,对客户端传来的哈希值、服务端存储的盐值计算摘要结果。
1
result = SHA256(authentication_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
比较上一步的结果和数据库储存的哈希值是否相同,如果相同那么密码正确,反之密码错误。
1
authentication = compare(result, server_hash) // yes