23 _ 认证:系统如何正确分辨操作用户的真实身份? 你好,我是周志明。

我们应该都很清楚,对于软件研发来说,即使只限定在“软件架构设计”这个语境下,系统安全仍然是一个很大的话题。它不仅包括“防御系统被黑客攻击”这样狭隘的安全,还包括一些与管理、运维、审计等领域主导的相关安全性问题,比如说安全备份与恢复、安全审计、防治病毒,等等。

不过在这门课程里,我们的关注重点并不会放在以上这些内容上,我们所谈论的软件架构安全,主要包括(但不限于)以下这些问题的具体解决方案:

  • 认证(Authentication):系统如何正确分辨出操作用户的真实身份?
  • 授权( Authorization):系统如何控制一个用户该看到哪些数据、能操作哪些功能?
  • 凭证(Credentials):系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
  • 保密(Confidentiality):系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
  • 传输(Transport Security):系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?
  • 验证(Verification):系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险?

由于跟安全相关的问题,一般都不会给架构设计直接创造价值,而且解决起来又很繁琐复杂、费时费力,所以可能会经常性地被一部分开发人员给有意无意地忽略掉。

不过庆幸的是,这些问题基本上也都是与具体系统、具体业务无关的通用性问题,这就意味着它们往往会存在一些业界通行的、已经被验证过是行之有效的解决方案,乃至已经形成了行业标准,不需要我们再从头去构思如何解决。

所以,在“安全架构”这个小章节里,我会花六讲的时间,围绕系统安全的标准方案,带你逐一探讨以上这些问题的处理办法,并会以Fenix’s Bookstore作为案例实践。而出于方便你进行动手实操的目的,我不会在课程中直接贴出大段的项目代码(当然必要的代码示例还是会有的),所以我建议你要结合着从Fenix’s Bookstore的GitHub仓库中获取的示例代码来进行学习。

好,那么今天这节课,我们就从“认证”这个话题开始,一起来解决“系统如何正确分辨操作用户的真实身份”这个问题。

什么是认证?

认证(Authentication)、授权(Authorization)和凭证(Credentials)这三项可以说是一个系统中最基础的安全设计了,哪怕是再简陋的信息系统,大概也不可能忽略掉“用户登录”这个功能。

信息系统在为用户提供服务之前,总是希望先弄清楚“你是谁?”(认证)、“你能干什么?”(授权)以及“你如何证明?”(凭证)这三个基本问题的答案。然而,认证、授权与凭证这三个基本问题,又并不像部分开发者认为的那样,只是一个“系统登录”功能而已,仅仅是校验一下用户名、密码是否正确这么简单。

账户和权限信息作为一种必须最大限度保障安全和隐私,同时又要兼顾各个系统模块、甚至是系统间共享访问的基础主数据,它的存储、管理与使用都面临一系列复杂的问题。

因此,对于某些大规模的信息系统,账户和权限的管理往往要由专门的基础设施来负责,比如微软的活动目录(Active Directory,AD)或者轻量目录访问协议(Lightweight Directory Access Protocol,LDAP),跨系统的共享使用问题甚至还会用到区块链技术来解决。

另外,还有一个不少人会先入为主的认知偏差:尽管“认证”是解决“你是谁?”的问题,但这里的“你”并不一定是个人(真不是在骂你),也很有可能是指外部的代码,即第三方的类库或者服务。

因为最初在计算机软件当中,对代码认证的重要程度甚至要高于对最终用户的认证,比如早期的Java系统里,安全中的认证默认是特指“代码级安全”,即你是否信任要在你的电脑中运行的代码

这是由Java当时的主要应用形式Java Applets所决定的:类加载器从远端下载一段字节码,以Applets的形式在用户的浏览器中运行,由于Java的语言操控计算机资源的能力要远远强于JavaScript,所以系统必须要先确保这些代码不会损害用户的计算机,否则就谁都不敢去用。

这一阶段的安全观念,就催生了现在仍然存在于Java技术体系中的“安全管理器”(java.lang.SecurityManager)、“代码权限许可”(java.lang.RuntimePermission)等概念。到了现在,系统对外部类库和服务的认证需求依然很普遍,但相比起五花八门的最终用户认证来说,代码认证的研究发展方向已经很固定了,基本上都是统一到证书签名上。

不过在咱们这节课里,对认证的探究范围只限于对最终用户的认证。关于对代码的认证,我会安排在“分布式的基石”模块中的第40讲“服务安全”来讲解。

好,那么在理解了什么是认证、界定了认证的范围之后,我们接下来看一下软件工业界是如何进行认证的。

认证的标准

在世纪之交,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内容的认证。

关于第一点“信道上的认证”,由于它涉及的内容较多,又与后面第28、29讲要介绍的微服务安全方面的话题关系密切,所以这节课我就不展开讲了(而且J2EE中的Client-Cert其实并不是用于TLS的,以它引出TLS并不合适)。

那么接下来,我们就针对后两种认证方式,来看看它们各自都有什么样的实现特点和工作流程。

基于通讯协议:HTTP认证

前面我在介绍J2EE 1.2这项老技术的时候,已经提前用到了一个技术名词,“认证方案”(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认证框架的工作流程如下面的时序图所示:

不过,只有这种概念性的介绍,你可能还是会觉得有点儿枯燥和抽象,接下来我就以最基础的认证方案HTTP Basic Authentication为例,来给你解释下认证具体是如何工作的。

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”

此时,浏览器必须询问最终用户,要求提供用户名和密码,并会弹出类似下图所示的HTTP Basic认证窗口:

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

服务端接收到请求,解码后检查用户名和密码是否合法,如果合法就允许返回/admin的资源,否则就返回403 Forbidden禁止下一步操作。

这里要注意一点,Base64只是一种编码方式,而并不是任何形式的加密,所以Basic认证的风险是显而易见的,它只能是一种以演示为主要目的的认证方案

那么,除Basic认证外,IETF还定义了很多种可用于实际生产环境的认证方案,比如:

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

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

因此,很多厂商也扩展了自己的认证方案,比如:

  • AWS4-HMAC-SHA256:相当简单粗暴的名字,就是亚马逊AWS基于HMAC-SHA256哈希算法的认证。
  • NTLM / Negotiate:这是微软公司NT LAN Manager(NTLM)用到的两种认证方式。
  • Windows Live ID:这个顾名思义即可。
  • Twitter Basic:一个不存在的网站所改良的HTTP基础认证。
  • ……

好,说完了基于通讯协议的认证方案,我们再来看看基于通讯内容的Web认证是如何实现的。

基于通讯内容:Web认证

IETF为HTTP认证框架设计了可插拔(Pluggable)的认证方案,原本是希望能涌现出各式各样的认证方案,去支持不同的应用场景。尽管前面我也列举了一些还算常用的认证方案,但目前的信息系统,尤其是在系统对终端用户的认证场景中,直接采用HTTP认证框架的比例其实是非常低的。

这也不难理解,HTTP是“超文本传输协议”,传输协议的根本职责是把资源从服务端传输到客户端,至于资源具体是什么内容,只能由客户端自行解析驱动。所以说,以HTTP协议为基础的认证框架,也只能面向传输协议而不是具体传输内容来设计

如果用户想要从服务器中下载文件,弹出一个HTTP服务器的对话框让用户登录,是可以接受的;但如果用户访问信息系统中的具体服务,身份认证肯定希望是由系统本身的功能去完成的,而不是由HTTP服务器来负责认证。

那么,这种依靠内容而不是传输协议来实现的认证方式,在万维网里就被称为“Web认证”,由于在实现形式上,登录表单占了绝对的主流,因此它通常也被称为“表单认证”(Form Authentication)。

实际上,直到2019年之前,表单认证都没有什么行业标准可循,表单长什么样子、其中的用户字段、密码字段、验证码字段、是否要在客户端加密、采用何种方式加密、接受表单的服务地址是什么等等,都完全由服务端与客户端的开发者自行协商决定。

可“没有标准的约束”,反倒成了表单认证的一大优点,表单认证允许我们做出五花八门的页面,各种程序语言、框架或开发者本身,都可以自行决定认证的全套交互细节。

到这里你可能要说了,在前面讲认证标准的时候,我说“遵循规范、别造轮子就是最恰当的安全”,这里我又把表单认证的高自由度说成是一大优点,好话都让我给说全了。

其实啊,我提倡用标准规范去解决安全领域的共性问题,这条原则完全没有必要与界面是否美观合理、操作流程是否灵活便捷这些应用需求对立起来。

比如,想要支持密码或扫码等多种登录方式、想要支持图形验证码来驱逐爬虫与机器人、想要支持在登录表单提交之前进行必要的表单校验,等等,这些需求都很具体,不具备写入标准规范的通用性,但它们都具备足够的合理性,应当在实现层面去满足。

同时,如何控制权限保证不产生越权操作、如何传输信息保证内容不被窃听篡改、如何加密敏感内容保证即使泄漏也不被逆推出明文,等等,这些问题也已经有了通行的解决方案,明确定义在规范之中,因此也应当在架构层面去遵循。

所以说,表单认证与HTTP认证不见得是完全对立的,它们分别有不同的关注点,可以结合使用。就以Fenix’s Bootstore的登录功能为例,这个项目的页面表单是一个自行设计的Vue.js页面,但认证的整个交互过程,就遵循了OAuth 2.0规范的密码模式来完成。

2019年3月,万维网联盟批准了由FIDO(Fast IDentity Online,一个安全、开放、防钓鱼、无密码认证标准的联盟)领导起草的世界首份Web内容认证的标准“WebAuthn”(在这节课里,我们只讨论WebAuthn,不会涉及CTAP、U2F和UAF)。如果你的思维很严谨的话,可能又会觉得奇怪和矛盾了:不是才说了Web表单长什么样、要不要验证码、登录表单是否在客户端校验等等,是十分具体的需求,不太可能定义在规范上的吗?

确实如此,所以WebAuthn彻底抛弃了传统的密码登录方式,改为直接采用生物识别(指纹、人脸、虹膜、声纹)或者实体密钥(以USB、蓝牙、NFC连接的物理密钥容器)来作为身份凭证,从根本上消灭了用户输入错误产生的校验需求,以及防止机器人模拟产生的验证码需求等问题,甚至连表单界面都可能省略掉,所以这个规范不关注界面该是什么样子、要不要验证码、是否要前端校验等这些问题。

不过,由于WebAuthn相对比较复杂,在学习后面的内容之前,我建议如果你的设备和环境允许的话,可以先在GitHub网站的2FA认证功能中,实际体验一下通过WebAuthn完成的两段式登录。

在硬件方面,需要你用带有TouchBar的MacBook,或者其他支持指纹、FaceID验证的手机均可,现在应该在售的移动设备基本都带有生物识别的装置了。在软件方面,直至iOS13.6,iPhone和iPad都不支持WebAuthn,但Android和macOS系统中的Chrome,以及Windows的Edge浏览器,都已经可以正常使用WebAuthn了。

WebAuthn规范涵盖了“注册”与“认证”两大流程,我先来介绍下注册流程的大致步骤:

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

你可以参考一下这个注册步骤的时序图:

登录流程其实跟注册流程差不多,如果你理解了注册流程,登录就比较简单了,大致可以分为这样几个步骤:

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

WebAuthn采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案。私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;公钥是公开的,可以被任何人看到或存储。

另外,公钥可用于验证私钥生成的签名,但不能用来签名,除了得知私钥外,没有其他途径能够生成可被公钥验证为有效的签名,这样服务器就可以通过公钥是否能够解密,来判断最终用户的身份是否合法。

而且,WebAuthn还一揽子地解决了传统密码在网络传输上的风险,在“保密”一节课中,我们还会讲到无论密码是否在客户端进行加密、如何加密,对防御中间人攻击来说都是没有意义的。

更值得夸赞的是,WebAuthn还为登录过程带来了极大的便捷性,不仅注册和验证的用户体验十分优秀,而且彻底避免了用户在一个网站上泄漏密码,所有使用相同密码的网站都受到攻击的问题,这个优点可以让用户不需要再为每个网站想不同的密码。

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

认证的实现

OK,在了解了业界标准的认证规范以后,我们再来看看在Java技术体系内,通常都是如何实现安全认证的。

Java其实也有自己的认证规范,第一个系统性的Java认证规范发布于Java 1.3时代,Sun公司提出了同时面向代码级安全和用户级安全的认证授权服务JAAS(Java Authentication and Authorization Service,1.3处于扩展包中,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协议中定义的各种认证、表单等认证方式确认用户身份,这也是这节课所探讨的主要话题。
  • 安全上下文:用户获得认证之后,要开放一些接口,让应用可以得知该用户的基本资料、用户拥有的权限、角色,等等。
  • 授权功能:判断并控制认证后的用户对什么资源拥有哪些操作许可,这部分内容我会在下一节课讲“授权”时介绍。
  • 密码的存储与验证:密码是烫手的山芋,不管是存储、传输还是验证,都应该谨慎处理,这部分内容我会放到“保密”一讲去具体讨论。

小结

这节课,我们了解了信道、协议和内容这三种主要标准化认证类型的其中两种,分别是HTTP认证(协议)和Web认证(内容)。现在你应该就很清楚HTTP认证和Web认证的特点了,那就是认证的载体不一样,决定了认证的形式和功能范围都有不同。

另外我还给你介绍了它们各自的工作流程,其中你要关注的重点是认证框架的整体的运作,不必一下子陷入到具体的认证方案上去。

除此之外,我还介绍了认证标准在Java中的落地实现。在Java技术体系中,原本也有自己的认证标准与实现,那就是依赖于JAAS的面向代码级和用户级的安全机制,不过目前应用更广泛的反而是两个私有的安全框架,这又是一个官方标准被民间草根框架击败的例子,可见软件中设计必须贴近实际用户,才能达到实用的效果。

一课一思

我相信你公司的系统一定也会使用用户登录功能,那么它是标准化的认证吗?是如何实现的呢?

欢迎给我留言,分享你的答案。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e5%91%a8%e5%bf%97%e6%98%8e%e7%9a%84%e6%9e%b6%e6%9e%84%e8%af%be/23%20_%20%e8%ae%a4%e8%af%81%ef%bc%9a%e7%b3%bb%e7%bb%9f%e5%a6%82%e4%bd%95%e6%ad%a3%e7%a1%ae%e5%88%86%e8%be%a8%e6%93%8d%e4%bd%9c%e7%94%a8%e6%88%b7%e7%9a%84%e7%9c%9f%e5%ae%9e%e8%ba%ab%e4%bb%bd%ef%bc%9f.md