最近正好在梳理系统里的登录逻辑,有关登录的话题,那真的是三天三夜也讲不完,今天仅仅挑其中的一个小细节聊聊。
# 从用户的视角看
假设我们使用最基本的用户名密码登录。登录过程是什么样的呢?从用户的视角看,大致是这样的:
首先,进入登录页面。
接着,输入用户名和密码,点击登录。
最后,登录成功,进入系统内,此后一段时间内就可以正常使用系统了。
上面的登录过程大致可以分为两个部分:
- 登录动作:输入用户名密码后点击登录按钮。
- 登录状态维持:后续一段时间内无需再次登录。
我们通常所讲的登录,就是要实现上面两个部分。实际上如果从技术实现的角度上看,这两个部分本质上是一样的,只是形态不同而已。
# 身份认证
比如,登录动作,就是向后端发起一个请求(附带着用户名和密码),后端通过用户名和密码这两个信息校验确定用户身份。
而登录状态维持,其实也是通过某种方式,让后端能确定出该请求所属的用户身份是谁。
可以看到,上面提到的两个过程,其实都是在做一件事情:“确定当前用户是谁”,而这就是认证过程(Authentication),也叫做认证、鉴权。
怎么做到身份认证呢?就有很多办法了。
比如上面提到的登录状态维持,如果是一般的 http 请求,可以用 cookie-session 的机制实现,也可以用 jwt 的方式做。而如果所有请求都是走一个连接,例如 websocket,那登录状态天然就是可维持的,什么都不用做(因为相同连接可以直接复用登录上下文信息)。
我曾经特别喜欢问的一个关于 websocket 的面试问题就是:ws 是如何做到登录状态维持的呢?现在你知道答案了,ws 不需要登录状态维持。
# 如何身份认证
这里我们重点聊聊 http 请求的认证问题。
http 请求要想被认证,那请求里一定得带点什么标识用户身份信息的东西对吧,这样后端才能根据这个东西确定你到底是谁,这个东西我们叫做 token。有很多种类的 token,不过这个话题不是本文的重点。
那么 http 请求能够怎样携带 token 呢?有以下几种方式:
- 放在请求 url 里。
- 放在请求 header 里。
- 放在请求 body 里。
这三种方式都是可以的,但是各有利弊。
# token 放在 url 里
比如放在 url 里,不太安全,毕竟 url 是什么很容易就被看到了。倒不是说不能放在 url 里,只是说如果放在 url 里,那你的 token 本身需要带有一定的安全限制,比如说有限使用次数、有使用期限、只能具有受限的权限等等。
把 token 放在 url 里的认证方式,学名叫做 “bearer token”,翻译成中文就是:票据携带者。感兴趣的同学可以自行搜索这个名字的含义,RFC 也有对应的规范可以阅读。
用一次就失效的 bearer token,通常叫 code,比如 OAuth2 协议中的 code。
bearer token 最大的好处就是简单,当然这里的简单指的是用户使用起来很简单,只需要访问一个 url 就可以了,因此 bearer token 非常常见。例如只要点击一个连接或者点击一个按钮就可以登录的方式,都是基于 bearer token 实现的。
bearer token 除了简单以外,还有一个好处是它不需要被前端缓存。因为 token 就保存在 url 里,每次取的时候直接从 url 里取就行了,无需用 sessionStorage、localStorage 等做缓存。因此特别适合用在一些临时登录的场景里。比如我需要借用一下公共电脑登录一下,完事儿了只需要清理一下浏览记录即可,不需要清理 cookie、localStorage 等乱七八糟的地方。
在 url 中使用 bearer token 需要注意页面其他请求(例如 img)的 referrer 可能会泄露你的 token
# token 放在 header 里
放在 header 里的 token,大致有两种方式:
- 放在自定义 header 里。
- 放在 cookie header 里。
看似都是在 header 里,实际上有很大区别。
注意,为了描述起来方便,以下用 cookie 方案指代 cookie header 方案,用 header 指代自定义 header 方案。
# cookie 需要依赖浏览器,header 不用
cookie 的维护严重依赖浏览器行为。
cookie 是怎么种上的?是浏览器看到 response header 中的 set-cookie 然后记录下来的。这个过程无需其他人干预。
cookie 是怎么管理有效期的?是浏览器看到 set-cookie 里的 age 属性然后管理的。这个过程也无需其他人干预。
cookie 是怎么附带在请求上的?是浏览器在发请求的时候自动附加在请求上的。这个过程还是无需其他人干预。
前端构造 ajax 请求的时候虽然可以手动设置 cookie 这个 header,但实际上请求发出去的时候会被浏览器覆盖掉,所以你想干预都干预不了。
甚至对于具有 http-only 属性的 cookie,浏览器全权托管,禁止 js 访问,他们对前端来说就是透明的(transparent)。
其实 cookie 被发明之初并不是用来做认证的,只不过大家一看,好家伙,浏览器这么贴心什么都帮你做好了,这不省事儿嘛,所以大家就把 token 放到 cookie 里了。
没错,使用 cookie 方案最大的好处就是简单,省心,浏览器帮你做完了,但最大的问题就是你得是在浏览器环境里,如果你是在 android、ios 原生应用或者小程序里面,不好意思,有关 cookie 的那一堆破事儿就得你自己打补丁做了。
cookie 方案另外一个问题是,出于安全考虑,浏览器对 cookie 有很多限制,比如同源限制,same-site 限制,跨域限制等等,有时候这些限制会让你很难受,比如明明开发测试环境都是好的,发布到生产环境就坏了,你得去检查这些环境有关 cookie 的限制以及配置是否都是一致的。
而 header 方案就正相反,跟浏览器就没什么关系。token 怎么获取,怎么注入请求,怎么管理有效期,全都得自己写代码实现。累确实是累,而且写不好一旦出 bug 影响还很严重。不过好在不受浏览器的限制。
# 有些场景 cookie 做不到,有些场景 header 做不到
这是非常蛋疼的地方。
什么场景是 cookie 无能为力的呢?禁用 cookie 这种就不提了,有一种常见的支持不了的场景是登录隔离。比如你需要在浏览器中同时登录好多账号进行操作。因为 cookie 是根据域名绑定的,而且同名 cookie 只能保存一个,也就意味着 cookie 里面只能保留一个最新的 token,不同账号的登录状态会相互覆盖。不过有时候这反倒是一个优势。。比如你就是不想让用户多开,那么这种方式可以稍微提高一些用户多开的成本(用不同浏览器或者不同电脑仍然是可以多开的)。
什么场景是 header 无能为力的呢?不受前端代码影响的请求就没辙。比如 img 标签、link 标签、script 标签发出的 GET 请求,这些都是浏览器自己发出去的,无法注入 header 在这上面。再比如当用户在浏览器中输入一个 url 并按下回车的时候,这个 url 实际上是向后端请求了一个 html 文件,而这个请求也是无法注入 header 的。再比如使用原始的 form 表单提交的时候,表单提交的那个 post 请求也是浏览器发出去的,所以也不能注入 header。闹了半天,header 只能用在 ajax 请求中,只要某个请求不是 ajax 请求,header 没法控制了。
非 ajax 请求无法使用 header 方案,这个限制的影响面要更大一些。比如当用户访问某个页面的时候:
如果是 cookie 方案,那么在请求 html 的时候,后端就已经可以根据 cookie 验证当前用户是谁,是否需要登录,如果不需要登录就自动跳转到登录页面。
如果是 header 方案,因为 html 请求无法携带 header,只能等 html 返回以后,浏览器解析 js,js 再发出一个 ajax 请求到后端,后端才可以得知当前用户是谁,是否需要登录。如果还未登录,再由前端 js 跳转到登录页面(因为 ajax 请求无法让页面跳转)。
总之就是会让登录校验过程变得前后端耦合在一起,而且整个流程变得很长(html 请求返回、解析 html、解析 js、执行 js、发出 ajax 请求、处理返回结果),耗时也会更久。
另一个受影响的场景是灰度访问。如果你是需要根据用户的身份信息来决定用户打开的是 A 版本还是 B 版本,那么基于 header 的认证方案就无法对 html 做灰度,也就是说 html 永远只能是一个版本。当然这个就说来话长了,以后有机会再聊(挖坑+1)。
# cookie 是后端方案,header 是前端方案
cookie 是后端方案,header 是前端方案。这句话怎么理解呢?
如果你选择 header 的方式,那么在发请求的时候就需要手动设置自定义 header。这个过程是前端代码控制的,前端不写点代码是做不到的。
如果你选择 cookie 的方式,正如前面所说,cookie 的各种操作都是浏览器帮你做了。而通常 cookie 又是后端通过 set-cookie 种上去的(尤其是对于 http-only 的 cookie)。所以说,这个过程可以认为是后端代码控制的,前端可以不写任何代码。
因为认证过程一定是有后端工作量的,既然如此,前端就别来掺和了,全部由后端搞定即可,这样也更有利于维护。所以我是更推荐使用后端方案的,即使用 cookie 方案。但实际上我发现很多后端工程师对 cookie 的理解非常薄弱(其实不少前端工程师也一样。。),毕竟 cookie 的管理机制非常依赖浏览器,算是前端知识了。这导致很多时候他们都不知道如何正确使用 cookie,设计出来的方案也是非常奇怪。。
除了开发和维护,前端方案和后端方案的另一个区别就在于调试,前端方案调试起来相对容易些。pc 端浏览器二者区别不大,可如果是移动端浏览器,或者是一些 webview 环境中,采用前端方案,你还可以仅通过改变前端代码或者修改前端 token 缓存来调试登录过程,后端方案嘛,如果不拉着后端工程师或者亲自改后端那就真的是无能为力了。
毕竟,责任和权利总是相匹配的。
# token 放在 body 里
除了 url、header,最后一种选择就是放在 body 里了,不过实际上很少有人这么做,有点憨憨。原因也很简单:
首先 GET 请求是没有 body 的,这意味放在 body 的方式无法支持 GET 请求的认证。
其次 body 通常是用来传输数据的,格式五花八门,有 json、text、form,甚至还可能是二进制,怎么跟数据共存是个蛋疼问题。
总之,通常没人会把 token 放 body 里,除非是有一些非常特殊的情况。
# 总结
上面啰嗦了一大堆,其实我们只是在讲一个问题:token 要放在哪里。至于 token 怎么生成?token 在后端如何校验?多种 token 方案如何共存?如何设计登录方案?等等等等,真的是三天三夜也讲不完。
最后总结一下全文的重点:
认证过程就是回答这是哪个用户的问题。
登录和登录状态维持其实都是认证过程。
标识用户身份的唯一标识,叫做 token。
对于 http 请求的认证,可以把 token 放在 url、header、body 中。
放在 url 中的 token 叫做 bearer token。
放在 header 中的 token 有自定义 header 和 cookie header 两种方案,各有利弊。
一般没人把 token 放在 body 中。