认证

认证(Authentication)系统如何正确分辨出操作用户的真实身份?

认证、授权和凭证可以说是一个系统中最基础的安全设计,哪怕再简陋的信息系统,大概也不可能忽略掉“用户登录”功能。信息系统为用户提供服务之前,总是希望先弄清楚“你是谁?”(认证)、“你能干什么?”(授权)以及“你如何证明?”(凭证)这三个基本问题。然而,这三个基本问题又并不像部分开发者认为的那样,只是一个“系统登录”功能,仅仅是校验一下用户名、密码是否正确这么简单。账户和权限信息作为一种必须最大限度保障安全和隐私,同时又要兼顾各个系统模块甚至系统间共享访问的基础主数据,它的存储、管理与使用都面临一系列复杂的问题。对于某些大规模的信息系统,账户和权限的管理往往要由专门的基础设施来负责,譬如微软的活动目录(Active Directory,AD)或者轻量目录访问协议(Lightweight Directory Access Protocol,LDAP),跨系统的共享使用甚至会用到区块链技术。

另外还有一个认知偏差:尽管“认证”是解决“你是谁?”的问题,但这里的“你”并不一定是指人(真不是在骂你),也可能是指外部的代码,即第三方的类库或者服务。最初,对代码认证的重要程度甚至高于对最终用户的认证,譬如在早期的 Java 系统里,安全认证默认是特指“代码级安全”,即你是否信任要在你的电脑中运行的代码。这是由 Java 当时的主要应用形式——Java Applets 所决定的:类加载器从远端下载一段字节码,以 Applets 的形式在用户的浏览器中运行,由于 Java 操控计算机资源的能力要远远强于 JavaScript,因此必须先确保这些代码不会损害用户的计算机,否则就谁都不敢去用。这一阶段的安全观念催生了现在仍然存在于 Java 技术体系中的“安全管理器”(java.lang.SecurityManager)、“代码权限许可”(java.lang.RuntimePermission)等概念。如今,对外部类库和服务的认证需求依然普遍,但相比起五花八门的最终用户认证来说,代码认证的研究发展方向已经很固定,基本上都统一到证书签名上。在本节中,认证的范围只限于对最终用户的认证,而代码认证会安排在“分布式的基石”中的“ 服务安全 ”去讲解。

认证的标准

世纪之交,Java 迎来了 Web 时代的辉煌,互联网的迅速兴起促使 Java 进入了快速发展时期。这时候,基于 HTML 和 JavaScript 的超文本 Web 应用迅速超过了“Java 2 时代”之前的 Java Applets 应用,B/S 系统对最终用户认证的需求使得“安全认证”的重点逐渐从“代码级安全”转为“用户级安全”,即你是否信任正在操作的用户。在 1999 年,随 J2EE 1.2(它是 J2EE 的首个版本,为了与 J2SE 同步,初始版本号直接就是 1.2)发布的 Servlet 2.2 中,添加了一系列用于认证的 API,主要包括下列两部分内容:

  • 标准方面,添加了四种内置的、不可扩展的认证方案,即 Client-Cert、Basic、Digest 和 Form。
  • 实现方面,添加了与认证和授权相关的一套程序接口,譬如HttpServletRequest::isUserInRole()HttpServletRequest::getUserPrincipal()等方法。

一项发布超过 20 年的老旧技术,原本并没有什么专门提起的必要性,笔者之所以引用这件事,是希望从它包含的两部分内容中引出一个架构安全性的经验原则:以标准规范为指导、以标准接口去实现。安全涉及的问题很麻烦,但解决方案已相当成熟,对于 99%的系统来说,在安全上不去做轮子,不去想发明创造,严格遵循标准就是最恰当的安全设计。

引用 J2EE 1.2 对安全的改进还有另一个原因,它内置的 Basic、Digest、Form 和 Client-Cert 这四种认证方案都很有代表性,刚好分别覆盖了通信信道、协议和内容层面的认证。而这三种层面认证恰好涵盖了主流的三种认证方式,具体含义和应用场景列举如下。

  • ** 通信信道上的认证** :你和我建立通信连接之前,要先证明你是谁。在网络传输(Network)场景中的典型是基于 SSL/TLS 传输安全层的认证。
  • ** 通信协议上的认证** :你请求获取我的资源之前,要先证明你是谁。在互联网(Internet)场景中的典型是基于 HTTP 协议的认证。
  • ** 通信内容上的认证** :你使用我提供的服务之前,要先证明你是谁。在万维网(World Wide Web)场景中的典型是基于 Web 内容的认证。

关于通信信道上的认证,由于内容较多,又与后续介绍微服务安全方面的话题关系密切,将会独立放到本章的“传输”里,而且 J2EE 中的 Client-Cert 其实并不是用于 TLS 的,以它引出 TLS 并不合适。下面重点了解基于通信协议和通信内容的两种认证方式。

HTTP 认证

前文已经提前用到了一个技术名词——认证方案(Authentication Schemes),它是指生成用户身份凭证的某种方法,这个概念最初源于 HTTP 协议的认证框架(Authentication Framework)。IETF 在RFC 7235中定义了 HTTP 协议的通用认证框架,要求所有支持 HTTP 协议的服务器,在未授权的用户意图访问服务端保护区域的资源时,应返回 401 Unauthorized 的状态码,同时应在响应报文头里附带以下两个分别代表网页认证和代理认证的 Header 之一,告知客户端应该采取何种方式产生能代表访问者身份的凭证信息:

WWW-Authenticate: <认证方案> realm=<保护区域的描述信息>
Proxy-Authenticate: <认证方案> realm=<保护区域的描述信息>

接收到该响应后,客户端必须遵循服务端指定的认证方案,在请求资源的报文头中加入身份凭证信息,由服务端核实通过后才会允许该请求正常返回,否则将返回 403 Forbidden 错误。请求头报文应包含以下 Header 项之一:

Authorization: <认证方案> <凭证内容>
Proxy-Authorization: <认证方案> <凭证内容>

HTTP 认证框架提出认证方案是希望能把认证“要产生身份凭证”的目的与“具体如何产生凭证”的实现分离开来,无论客户端通过生物信息(指纹、人脸)、用户密码、数字证书抑或其他方式来生成凭证,都属于是如何生成凭证的具体实现,都可以包容在 HTTP 协议预设的框架之内。HTTP 认证框架的工作流程如图 5-1 所示。

图 5-1 HTTP 认证框架的工作流程时序图

以上概念性的介绍可能会有些枯燥抽象,下面笔者将以最基础的认证方案——HTTP Basic 认证为例来介绍认证是如何工作的。HTTP Basic 认证是一种主要以演示为目的的认证方案,也应用于一些不要求安全性的场合,譬如家里的路由器登录等。Basic 认证产生用户身份凭证的方法是让用户输入用户名和密码,经过 Base64 编码“加密”后作为身份凭证。譬如请求资源GET /admin后,浏览器会收到来自服务端的如下响应:

HTTP/1.1 401 Unauthorized
Date: Mon, 24 Feb 2020 16:50:53 GMT
WWW-Authenticate: Basic realm="example from icyfenix.cn"

此时,浏览器必须询问最终用户,即弹出类似图 5-2 所示的 HTTP Basic 认证对话框,要求提供用户名和密码。.

Basic-Authentication 图 5-2 HTTP Basic 认证对话框

用户在对话框中输入密码信息,譬如输入用户名icyfenix,密码123456,浏览器会将字符串icyfenix:123456编码为aWN5ZmVuaXg6MTIzNDU2,然后发送给服务端,HTTP 请求如下所示:

GET /admin HTTP/1.1
Authorization: Basic aWN5ZmVuaXg6MTIzNDU2

服务端接收到请求,解码后检查用户名和密码是否合法,如果合法就返回/admin的资源,否则就返回 403 Forbidden 错误,禁止下一步操作。注意 Base64 只是一种编码方式,并非任何形式的加密,所以 Basic 认证的风险是显而易见的。除 Basic 认证外,IETF 还定义了很多种可用于实际生产环境的认证方案,列举如下。

  • ** Digest** :RFC 7616,HTTP 摘要认证,可视为 Basic 认证的改良版本,针对 Base64 明文发送的风险,Digest 认证把用户名和密码加盐(一个被称为 Nonce 的变化值作为盐值)后再通过 MD5/SHA 等哈希算法取摘要发送出去。但是这种认证方式依然是不安全的,无论客户端使用何种加密算法加密,无论是否采用了 Nonce 这样的动态盐值去抵御重放和冒认,遇到中间人攻击时依然存在显著的安全风险。关于加解密的问题,将在“ 保密 ”小节中详细讨论。
  • ** Bearer** :RFC 6750,基于 OAuth 2 规范来完成认证,OAuth2 是一个同时涉及认证与授权的协议,在“ 授权 ”小节将详细介绍 OAuth 2。
  • ** HOBA** :RFC 7486 ,HOBA(HTTP Origin-Bound Authentication)是一种基于自签名证书的认证方案。基于数字证书的信任关系主要有两类模型:一类是采用 CA(Certification Authority)层次结构的模型,由 CA 中心签发证书;另一种是以 IETF 的 Token Binding 协议为基础的 OBC(Origin Bound Certificate)自签名证书模型。在“ 传输 ”小节将详细介绍数字证书。

HTTP 认证框架中的认证方案是允许自行扩展的,并不要求一定由 RFC 规范来定义,只要用户代理(User Agent,通常是浏览器,泛指任何使用 HTTP 协议的程序)能够识别这种私有的认证方案即可。因此,很多厂商也扩展了自己的认证方案。

  • AWS4-HMAC-SHA256*:亚马逊 AWS 基于 HMAC-SHA256 哈希算法的认证。
  • NTLM / Negotiate :这是微软公司 NT LAN Manager(NTLM)用到的两种认证方式。
  • Windows Live ID :微软开发并提供的“统一登入”认证。
  • Twitter Basic :一个不存在的网站所改良的 HTTP 基础认证。
  • ……

Web 认证

IETF 为 HTTP 认证框架设计了可插拔(Pluggable)的认证方案,原本是希望能涌现出各式各样的认证方案去支持不同的应用场景。尽管上节列举了一些还算常用的认证方案,但目前的信息系统,尤其是在系统对终端用户的认证场景中,直接采用 HTTP 认证框架的比例其实十分低,这不难理解,HTTP 是“超文本传输协议”,传输协议的根本职责是把资源从服务端传输到客户端,至于资源具体是什么内容,只能由客户端自行解析驱动。以 HTTP 协议为基础的认证框架也只能面向传输协议而不是具体传输内容来设计,如果用户想要从服务器中下载文件,弹出一个 HTTP 服务器的对话框,让用户登录是可接受的;但如果用户访问信息系统中的具体服务,身份认证肯定希望是由系统本身的功能去完成的,而不是由 HTTP 服务器来负责认证。这种依靠内容而不是传输协议来实现的认证方式,在万维网里被称为“Web 认证”,由于实现形式上登录表单占了绝对的主流,因此通常也被称为“表单认证"(Form Authentication)。

直至 2019 年以前,表单认证都没有什么行业标准可循,表单是什么样,其中的用户字段、密码字段、验证码字段是否要在客户端加密,采用何种方式加密,接受表单的服务地址是什么等,都完全由服务端与客户端的开发者自行协商决定。“没有标准的约束”反倒成了表单认证的一大优点,表单认证允许我们做出五花八门的页面,各种程序语言、框架或开发者本身都可以自行决定认证的全套交互细节。

可能你还记得开篇中说的“遵循规范、别造轮子就是最恰当的安全”,这里又将表单认证的高自由度说成是一大优点,好话都让笔者给说全了。笔者提倡用标准规范去解决安全领域的共性问题,这条原则完全没有必要与界面是否美观合理、操作流程是否灵活便捷这些应用需求对立起来。譬如,想要支持密码或扫码等多种登录方式、想要支持图形验证码来驱逐爬虫与机器人、想要支持在登录表单提交之前进行必要的表单校验,等等,这些需求十分具体,不具备写入标准规范的通用性,却具备足够的合理性,应当在实现层面去满足。同时,如何控制权限保证不产生越权操作、如何传输信息保证内容不被窃听篡改、如何加密敏感内容保证即使泄漏也不被逆推出明文,等等,这些问题已有通行的解决方案,明确定义在规范之中,也应当在架构层面去遵循。

表单认证与 HTTP 认证不见得是完全对立的,两者有不同的关注点,可以结合使用。以 Fenix's Bootstore 的登录功能为例,页面表单是一个自行设计的 Vue.js 页面,但认证的整个交互过程遵循 OAuth 2 规范的密码模式。

2019 年 3 月,万维网联盟(World Wide Web Consortium,W3C)批准了由FIDO(Fast IDentity Online,一个安全、开放、防钓鱼、无密码认证标准的联盟)领导起草的世界首份 Web 内容认证的标准“WebAuthn”(在这节里,我们只讨论 WebAuthn,不会涉及 CTAP、U2F 和 UAF),这里也许又有一些思维严谨的读者会感到矛盾与奇怪,不是才说了 Web 表单长什么样、要不要验证码、登录表单是否在客户端校验等等是十分具体的需求,不太可能定义在规范上的吗?确实如此,所以 WebAuthn 彻底抛弃了传统的密码登录方式,改为直接采用生物识别(指纹、人脸、虹膜、声纹)或者实体密钥(以 USB、蓝牙、NFC 连接的物理密钥容器)来作为身份凭证,从根本上消灭了用户输入错误产生的校验需求和防止机器人模拟产生的验证码需求等问题,甚至可以省掉表单界面,所以这个规范不关注界面该是什么样子、要不要验证码、是否要前端校验这些问题。

由于 WebAuthn 相对复杂,在阅读下面内容之前,如果你的设备和环境允许,建议先在GitHub 网站的 2FA 认证功能中实际体验一下如何通过 WebAuthn 完成两段式登录,再继续阅读后面的内容。硬件方面,要求用带有 Touch ID 的 MacBook,或者其他支持指纹、FaceID 验证的手机(目前在售的移动设备基本都带有生物识别装置)。软件方面,直至 iOS 13.6,iPhone 和 iPad 仍未支持 WebAuthn,但 Android 和 Mac OS 系统中的 Chrome,以及 Windows 的 Edge 浏览器都已经可以正常使用 WebAuthn 了。图 5-3 展示了使用 WebAuthn 登录不同浏览器的操作界面。

图 5-3 不同浏览器上使用 WebAuthn 登录的对比

WebAuthn 规范涵盖了“注册”与“认证”两大流程,先来介绍注册流程,它大致可以分为以下步骤:

  1. 用户进入系统的注册页面,这个页面的格式、内容和用户注册时需要填写的信息均不包含在 WebAuthn 标准的定义范围内。
  2. 当用户填写完信息,点击“提交注册信息”的按钮后,服务端先暂存用户提交的数据,生成一个随机字符串(规范中称为 Challenge)和用户的 UserID(在规范中称作凭证 ID),返回给客户端。
  3. 客户端的 WebAuthn API 接收到 Challenge 和 UserID,把这些信息发送给验证器(Authenticator),验证器可理解为用户设备上 TouchID、FaceID、实体密钥等认证设备的统一接口。
  4. 验证器提示用户进行验证,如果支持多种认证设备,还会提示用户选择一个想要使用的设备。验证的结果是生成一个密钥对(公钥和私钥),由验证器存储私钥、用户信息以及当前的域名。然后使用私钥对 Challenge 进行签名,并将签名结果、UserID 和公钥一起返回客户端。
  5. 浏览器将验证器返回的结果转发给服务器。
  6. 服务器核验信息,检查 UserID 与之前发送的是否一致,并用公钥解密后得到的结果与之前发送的 Challenge 相比较,一致即表明注册通过,由服务端存储该 UserID 对应的公钥。

以上步骤的时序如图 5-4 所示。

图 5-4 注册流程时序图

登录流程与注册流程类似,如果你理解了注册流程,就很容易理解登录流程了。登录流程大致可以分为以下步骤:

  1. 用户访问登录页面,填入用户名后即可点击登录按钮。
  2. 服务器返回随机字符串 Challenge、用户 UserID。
  3. 浏览器将 Challenge 和 UserID 转发给验证器。
  4. 验证器提示用户进行认证操作。由于在注册阶段验证器已经存储了该域名的私钥和用户信息,所以如果域名和用户都相同的话,就不需要生成密钥对了,直接以存储的私钥加密 Challenge,然后返回给浏览器。
  5. 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。

WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;公钥是公开的,可以被任何人看到或存储。公钥可用于验证私钥生成的签名,但不能用来签名,除了得知私钥外,没有其他途径能够生成可被公钥验证为有效的签名,这样服务器就可以通过公钥是否能够解密来判断最终用户的身份是否合法。

WebAuthn 还一揽子地解决了传统密码在网络传输上的风险,在“ 保密 ”一节中我们会讲到无论密码是否客户端进行加密、如何加密,对防御中间人攻击来说都是没有意义的。更值得夸赞的是 WebAuthn 为登录过程带来极大的便捷性,不仅注册和验证的用户体验十分优秀,而且彻底避免了用户在一个网站上泄漏密码,所有使用相同密码的网站都受到攻击的问题,这个优点使得用户无须再为每个网站想不同的密码。

当前的 WebAuthn 还很年轻,普及率暂时还很有限,但笔者相信几年之内它必定会发展成 Web 认证的主流方式,被大多数网站和系统所支持。

认证的实现

了解过业界标准的认证规范以后,这部分简要介绍一下在 Java 技术体系内通常是如何实现安全认证的。Java 其实也有自己的认证规范,第一个系统性的 Java 认证规范发布于 Java 1.3 时代,是由 Sun 公司提出的同时面向代码级安全和用户级安全的认证授权服务——JAAS(Java Authentication and Authorization Service,Java 认证和授权服务,Java 1.3 处于扩展包中,Java 1.4 时纳入标准包)。尽管 JAAS 已经考虑了最终用户的认证,但代码级安全在规范中仍然占更主要的地位。可能今天用过甚至听过 JAAS 的 Java 程序员都已经不多了,但是这个规范提出了很多在今天仍然活跃于主流 Java 安全框架中的概念,譬如一般把用户存放在“Principal”之中、密码存在“Credentials”之中、登录后从安全上下文“Context”中获取状态等常见的安全概念,都可以追溯到这一时期所定下的 API:

  • LoginModule (javax.security.auth.spi.LoginModule)
  • LoginContext (javax.security.auth.login.LoginContext)
  • Subject (javax.security.auth.Subject)
  • Principal (java.security.Principal)
  • Credentials(javax.security.auth.Destroyable、javax.security.auth.Refreshable)

JAAS 开创了这些沿用至今的安全概念,但规范本身实质上并没有得到广泛的应用,笔者认为有两大原因,一方面是由于 JAAS 同时面向代码级和用户级的安全机制,使得它过度复杂化,难以推广。在这个问题上 Java 社区一直有做持续的增强和补救,譬如 Java EE 6 中的 JASPIC、Java EE 8 中的 EE Security:

而另一方面,可能是更重要的一个原因,在 21 世纪的第一个十年里,以“With EJB”为口号,以 WebSphere、Jboss 等为代表的 J2EE 容器环境,与以“Without EJB”为口号、以 Spring、Hibernate 等为代表的轻量化开发框架产生了激烈的竞争,结果是后者获得了全面胜利。这个结果使得依赖于容器安全的 JAAS 无法得到大多数人的认可。

在今时今日,实际活跃于 Java 安全领域的是两个私有的(私有的意思是不由 JSR 所规范的,即没有 java/javax.*作为包名的)的安全框架:Apache ShiroSpring Security

相较而言,Shiro 更便捷易用,而 Spring Security 的功能则要复杂强大一些。无论是单体架构还是微服务架构的 Fenix's Bookstore,笔者都选择了 Spring Security 作为安全框架,这个选择与功能、性能之类的考量没什么关系,就只是因为 Spring Boot、Spring Cloud 全家桶的缘故。这里不打算罗列代码来介绍 Shiro 与 Spring Security 的具体使用,如感兴趣可以参考 Fenix's Bookstore 的源码仓库。只从目标上看,两个安全框架提供的功能都很类似,大致包括以下四类:

  • 认证功能:以 HTTP 协议中定义的各种认证、表单等认证方式确认用户身份,这是本节的主要话题。
  • 安全上下文:用户获得认证之后,要开放一些接口,让应用可以得知该用户的基本资料、用户拥有的权限、角色,等等。
  • 授权功能:判断并控制认证后的用户对什么资源拥有哪些操作许可,这部分内容会放到“ 授权 ”介绍。
  • 密码的存储与验证:密码是烫手的山芋,存储、传输还是验证都应谨慎处理,我们会放到“ 保密 ”去具体讨论。