网站开发最让人头疼的是什么?是用户系统。更让人头疼的是什么?这些用户要如何登录,之后的操作要如何鉴权。
初学 Web 开发大家遇到的第一个难关也许就是用户认证。接下来我将以 Stack Overflow 的一个基本问题为蓝本,同时引入 Oauth2 和 JWT,以生动形象的图片与解释,将用户认证的整个流程娓娓道来。
如果有条件的话,建议你直接阅读英文原版,也许会比我这个二道贩子说的更有所收获(逃
可以让你舒适阅读本文章的前置条件
了解如何构建一个表单,并将它提交至服务器的一个地址;(说白了就是 HTTP form 或者fetch/ajax)
知道有个东西叫数据库。
从用户注册开始
在传统的网页里,我们构建了一个表单 <form>
元素,然后利用 <form>
的 action 属性设置服务器用于接收表单的一个地址(又叫路由)。如果你用的是前端框架(如 React、Vue)来包装你的前台用户界面,你可能需要利用 Fetch 或 ajax 手动封装一个函数以发送表单内容。
接下来,让我们打开浏览器的开发者控制台,进入网络标签,监听接下来点击 “register” 按钮时发送的请求。
点击按钮时,浏览器(客户端)向服务器的指定路由 /register
发送了一个 POST 请求。POST 请求是进行表单提交时最常用的 HTTP 方法,在表单数据里记录了所有表单提交时输入框里记录的信息。这些数据将统一交给服务器进行处理。客户端提交数据完成后,数据处理的工作将转到服务器进行。
接下来我们看看服务器上会发生什么。
用户信息储存
毫无疑问,用户发送的所有注册信息,最后都会留存于服务器的数据库内。网站服务器接收到用户的表单信息后,这些数据会经过一些特殊处理后,自动存储与数据库的某一张表中。
举个例子,我们把数据就存到一张叫 UserInfo
的表里吧。这里的表单有几个字段:
字段名 | 备注 |
---|---|
username | 用户名 |
用户邮箱 | |
salt | 用户密码盐 |
passwordhash | 经盐处理后的用户密码 hash |
注意,这张表里有两个特殊的字段:salt 与 passwordhash,为什么要这么做?这是为了防止数据库泄露(俗称被拖库)造成的大规模密码外泄。
众所周知,数据库是服务器最重要的资产之一,一旦数据库内容外泄,对用户以及开发者造成的影响都是不可估量的。为了确保用户财产万无一失,最重要的用户密码绝对不能直接明文储存!
为了防止用户密码明文储存,早期所有的网站都会将用户密码进行一次 MD5 转换,即使被拖库也不会直接将密码暴露给攻击者。但是随着计算机算力的发展,彩虹表的诞生意味着以 MD5 储存密码也不太安全了。
那么,如何安全的储存密码呢?那就是为密码加盐。
所谓加盐,即在数据中加入一段干扰字符串再进行散列算法。生成的 Hash 可以有效的对抗彩虹表攻击。
传统的密码存储一般是直接 Hash ,容易被彩虹表攻击:
而加盐,则需要你在字符串的某个位置加入一些无关的字符串(即「盐」)以后,再进行 Hash,最后将盐和加密结果一同储存。这种方式可以最大限度的保证即使你的数据库泄露,用户也不会受到波及:
多次使用加密算法,能起到加盐的效果吗?
加盐的一个重要特性是每一个用户的盐应该尽量确保不同(唯一盐),从而让攻击者生成彩虹表的难度大大增加。如果复用同一套加密算法,加密算法是统一的,不能做到唯一盐,只要掌握了 hash 的顺序,也就等于不设防。
你可以选择很多元素生成盐:用户注册的时间、鼠标移动的轨迹、或者随机数生成工具……只要能保证这个盐与其它用户的都不同,那么它就是一个好盐。
最佳实践
加盐后 Hash 储存密码很有必要,但是这个操作不一定需要我们自己动手写(事实上也不推荐自己写)。推荐的做法是使用密钥派生函数。它们专门为了储存密码而生,只需要传入用户密码,他们就可以自动加盐、自动 Hash,一切都是那么的省心。
最推荐的密钥派生函数应该是 bcrypt,这个在 1999 年创造的函数到现在还在发光发热,只要传入明文密码,他就会生成一个包含盐的 Hash,你只需把这个 Hash 存储在密码数据库中即可。
储存完成用户密码以后,接下来你就可以完成一系列初始化操作,用户账号也就正式创建了。
用户登录:认证、授权、鉴权、权限控制
用户正式完成了注册,接下来,你可以引导用户用他们的注册时填写的表单信息登录网站,使用网站的各项功能了。
认证
与注册一样,用户填写表单,将表单数据 POST 到服务器,如果你在写注册模块时采取了最佳实践,那么只需根据用户名进行从数据库中取出对应的 Hash,与用户密码进行一次比对,只要用户输入的密码经过相同的散列算法后与数据库存储的结果比对正确,用户身份即认证成功。
授权
信息通过认证以后,必须要为用户找到一个体现其身份唯一性的方法,否则后期在用户进行特殊操作时鉴权会十分混乱。
中国古代有一个东西叫做虎符。虎符由中央政府发给掌兵大将,其背面刻有铭文,分为两半,右半存于朝廷,左半发给统兵将帅或地方长官,调兵时需要两半合对铭文才能生效。虎符专事专用,每支军队都有相对应的虎符。
服务器与客户机之间的「虎符」主要有两种:一种是服务器与客户端共存的 Session+Cookie,还有一种是与 RESTful API 非常契合的 JWT。
Session+Cookie
当服务器完成客户端的认证以后,服务器会自己生成一个 Session Token,这个 Token 服务器会将其 Hash 之后储存至表中,并同时发送至客户端。而客户端储存这个 Token 的区域,叫 Cookie。
Cookie 的具体解释可以参考维基百科的相关词条。
最佳实践
在浏览器写入 Cookie 时,推荐进行以下设置:
- 为 Cookie 设置
HttpOnly
和Secure
属性JavaScript
Document.cookie
API 无法访问带有HttpOnly
属性的cookie,可以预防 XSS 跨站攻击;标记为Secure
的 Cookie 只应通过被 HTTPS 协议加密过的请求发送给服务端,可以预防中间人攻击。
- 记住登录状态时的 Cookie 有效期不要超过 30 天
永远不要将 Cookie 的有效期设为永久,因为你永远都不知道用户会在什么地方登录你的网站,Cookie 有效期越久,用户信息暴露的风险也就越大。
- 服务器存储的 Session 应该经过处理
避免所有的敏感数据都直接暴露于数据库中。
JWT
传统的客户端 Cookie 认证会有投毒和窃取的风险,即使 HTTPS 与 Session 可以解决这两个问题,Cookie 还是有几个硬伤:
- Cookie 的长度上限只有 4KB,这意味着它无法存储更多的数据;
- 使用 Cookie 进行认证,必须符合同源策略。
如果两个 URL 的 protocol、port (如果有指定的话)和 host 都相同的话,则这两个 URL 是同源。这个方案也被称为“协议/主机/端口元组”,或者直接是 “元组”。(“元组” 是指一组项目构成的整体,双重/三重/四重/五重/等的通用形式)。
不符合同源策略的 Cookie 是不能跨域使用的。最简单的例子就是分布式服务器。大型网站为了保证网站稳定性以及用户在不同地区的访问体验,通常会在多个服务器上部署一套相同的网站服务。但是每个服务器的 IP 地址自然都不会一致,这样就不符合同源策略中的 Host 原则,Cookie 无法共享,也就是说如果用户连接中途如果切换了服务器,在原来服务器生成的 Cookie 将无法用于新的服务器。
为了适应现代化的前后端分离方案,JWT 应运而生。JWT 是 JSON Web Token 的缩写。它定义了一种能在客户端与服务器之间以 JSON 格式进行安全数据交互的方式,致力于解决现代化的前后端通信时的同源策略问题。由于整个信息都进行了数字签名,因此它是可信赖的。
一条 JWT 由三个部分组成:Header
(头部)、Payload
(负载)、Signature
(签名)
Header
Header 记录的信息一般包括两条:声明 Token 类型是 JWT 的 typ 属性、声明 Token 所使用签名算法的 alg 属性。
{ "alg": "HS256", "typ": "JWT" }
alg 属性可使用的签名算法很多:ES256、HS256、RS256……但是我们只需要 JWT 推荐的 HS256 签名算法即可。这些信息全部进行 Base64URL 转换后,将作为 JWT 的第一个部分。
Payload
Payload 包含了用户的身份信息,RFC 7519 标准规定了 7 个预定义字段供用户认证:
字段 | 说明 |
---|---|
“iss” (Issuer) | JWT 的签发者 |
“sub” (Subject) | JWT 主题 |
“aud” (Audience) | JWT 的接收者 |
“exp” (Expiration Time) | JWT 失效 Unix 时间戳,此时间后 JWT 将失效 |
“nbf” (Not Before) | JWT 生效 Unix 时间戳,此时间后 JWT 将生效 |
“iat” (Issued At) | JWT 签发 Unix 时间戳 |
“jti” (JWT ID) | 区别不同 JWT 的唯一 ID |
这 7 个预定义字段全部可选,你可以在 Payload 根据需要自由选用。同时你亦可以在 Payload 中添加自定义的字段。这些信息全部进行 Base64URL 转换后,将作为 JWT 的第二个部分。
由于 JWT 的 Header 和 Payload 仅进行 Base64URL 转换,因此不要在 Payload 中包含任何涉及用户隐私的敏感信息!Payload 是用来传输用户身份信息(用户名称、UID、用户权限等)的,不是用来传输表单数据的地方!永远不是!
Signature
上述两个部分的声明全部结束后,将前两个字符串用 . 进行拼接,并使用在 Header 中声明的加密算法进行一次加密运算后,就得到了 JWT 的第三个部分 Signature。
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
这个函数里接收了一个 Secret 参数作为加密的密码。但是为了确保安全,Secret 一般不会直接写明在程序里,你有两个地方可以储存 Secret :
- 生产环境系统里的环境变量;
- 单独存放至一个文件中。
将 Header、Payload、Signature 用 . 进行拼接,就是一个完整的 JWT。用 ES6 的模板字面量表示就是:
`${JWT.Header}.${JWT.Payload}.${JWT.Signature}`
让我们用一个动画完整的表示 JWT 授权的整个流程:
服务器生成 JWT 后将发送给客户端,根据服务端的设置,JWT 可以储存在 HTML5 新增的 LocalStorage / SessionStorage 中,但是很难抵挡 XSS 跨站攻击,最终还是应该放到 Cookie 里。
JWT 虽好,但是它有一个致命的缺陷:服务器无法直接吊销 JWT。这意味着除非 JWT 到期,服务器将无条件接受这个 JWT 的任何操作。如果想在服务端让 JWT 吊销,一个办法是更改服务器 Secret ,这会导致所有用户的 JWT 失效。另一个办法是再维护一张 JWT 黑名单表,表中的 JWT 将不会接受,但是这将与 Session 无异。
在详细解释了以上两种授权方式以后,我们可以总结出一些 Session 与 JWT 的使用场景:
对于传统的后端一把梭项目,可以使用 Session 的认证方式;
对于前后端分离的场景,为了做到尽可能的无状态,可以使用 JWT 认证方式。
什么叫无状态?
Session 是有状态的,服务器保存了你的登录信息,认证过程需要由服务器比对存储的内容与客户端是否一致。
JWT 是无状态的,登录信息仅在客户端上保存,服务器只做一件事:验证在客户端上的登录信息是否有效,无效则拒绝操作。
用一个通俗的例子来举例就是:
有状态:你在银行办了张银行卡,银行让你设置一个银行卡的密码,这个密码将会储存在银行的服务器上,取钱时输入密码,银行服务器将数据库存储的密码与你输入的密码进行比对,只有比对正确才能取钱。
无状态:某一天,银行突然给你发了条短信:我行推出无卡存取款服务。你只需要将银行卡绑定你的手机号,在 ATM 机上输入你的手机号码以及手机收到的短信验证码,就能直接取钱。在这个过程中,服务器没有储存任何密码,验证码的生成完全依靠算法进行,它只需要验证我给该手机发的短信验证码与用户输入的验证码是否一致。
鉴权
用户在网站进行的每一个涉及用户信息的操作都必须要进行鉴权以确保操作者是其本人发出。
采用 Session+Cookie 方式的网站,只需在发送的每一个 POST 请求中附上对应 Cookie 里的内容,服务器将请求里的 Cookie 值经过摘要算法计算后,若与自身数据库存储的 Session 比对一致,则鉴权通过,操作放行。否则拦截操作。
只要遵循同源策略或正确配置了跨源访问,则无需在发送请求时手动写入 Cookie,浏览器会帮我们搞定一切。
而 JWT 则更为简单。由于 JWT 是无状态的,只需将 JWT 的 Header 和 Payload 进行用服务器的 Secrect 进行一次重签名校验,即可完成鉴权。
权限控制
为了区分普通用户和管理员,我们一般都会对用户的权限进行控制,避免出现普通用户随意修改管理员功能造成网站崩溃。
最简单的办法,就是在用户的信息表中添加一条字段 is_admin,并在每一次鉴权的过程中确认用户是否由足够的权限去执行此操作,否则拒绝请求。
但是当网站的职能划分增多,不同的用户拥有不同的身份甚至多个身份时,也许你需要维护多张表来进行管理。
用户登出
用户完成网站操作后,应该注销登录并关闭网页。
若是采用 Session 鉴权,一般的网站服务器都会有一个 Session Destory 的函数,当用户发出手动注销登录时,手动执行该函数即可。
而 JWT 由于它无状态的特性,用户若想手动注销,只需将浏览器的 Cookie 或 LocalStroage 缓存清空,即可完成登出。
特殊事件
网站管理永远都不是一番风顺的,往往都会出点岔子。
忘记密码
永远把用户当成傻子。天知道他们会做出什么奇葩的事情,比如刚设置的密码转头就忘了。
各大网站的重置密码的方式基本由下列几个步骤组成:
- 用户向网站发出重置密码请求
- 网站生成随机字符验证码,一份明文通过邮件/短信等方式发送至给用户,一份 Hash 后写入到数据库中。
- 用户填入验证码与新密码,服务器鉴权后即可向用户对应字段写入新密码。
当然,有些网站可能不一样,比如发送的邮件里不是纯文本验证码,而是一个带有 Token 的网站链接,用户点击链接,并在 POST 请求中附上 Token 即可重置自己的密码。如此可以省去用户手动输入验证码的步骤,提升网站用户体验。
防御攻击
时刻提防 Script Boy,谁也不知道他们什么时候就到你家门口。
网站用户系统常见的攻击方式有两种:一种是注册机,对没有注册验证的网站堪称强力杀手,他们注册无用小号的速度,可以让数据库资源瞬间被榨干。另一种则是撞库,利用他们手上已有的数据库向你的登录接口发出相位猛冲,总会死马当活马医撞到几个用户,即使没有尝试成功,这些行为也与 DoS 无异,会让其他用户的网站体验大幅下降。
有效防御这两种攻击的最佳实践:一是注册时要求用户填写邮箱及注册系统发送的 Token,二者比对一致才能继续注册。二是在网站的用户认证环节增加验证码环节,Google 的 reCAPTCHA 已经不再向公众免费提供,可以改用 hCaptcha 或极验。三是多次尝试登录的用户进行封禁,在等待一段时间后自动解封。四是接入 CDN,如 Cloudflare,利用他们的黑名单功能,可以在攻击开始或未对网站服务造成极大影响前就将他们拦截在门外
以上最佳实践,可以大幅增加攻击者的时间成本和算力成本,迫使他们放弃对你的网站进行攻击。
总结
这是一篇几乎没有涉及到任何代码的用户认证系统导论,也是我在博客里写的第一篇长篇科普。事实上,现在的开源环境有着大把大把优质的轮子可以让你无痛生成一个优质的用户系统。但是对于想知根知底的同学来说,这篇文章应该是一个很好的开始。希望你可以不再对网站将要引入用户系统时感到恐惧。
另外,写到最后,才发现 Oauth2 和 OTP 验证都没有写上去,这些我将在《令人头疼的用户认证:番外篇》中继续为大家指点迷津,敬请期待。