原文连接:先后端接口鉴权全解javascript
不知不觉也写得比较长了,一次看不完建议收藏夹!本文主要解释与请求状态相关的术语(cookie、session、token)和几种常见登陆的实现方式,但愿你们看完本文后能够有比较清晰的理解,有感到迷惑的地方请在评论区提出。html
众所周知,http 是无状态协议,浏览器和服务器不可能凭协议的实现辨别请求的上下文。前端
因而 cookie 登场,既然协议自己不能分辨连接,那就在请求头部手动带着上下文信息吧。java
举个例子,之前去旅游的时候,到了景区可能会须要存放行李,被大包小包压着,旅游也不开心啦。在存放行李后,服务员会给你一个牌子,上面写着你的行李放在哪一个格子,离开时,你就能凭这个牌子和上面的数字成功取回行李。node
cookie 作的正是这么一件事,旅客就像客户端,寄存处就像服务器,凭着写着数字的牌子,寄存处(服务器)就能分辨出不一样旅客(客户端)。ios
你会不会想到,若是牌子被偷了怎么办,cookie 也会被偷吗?确实会,这就是一个很常被提到的网络安全问题——CSRF。能够在这篇文章了解关于 CSRF 的成因和应对方法。git
cookie 诞生初彷佛是用于电商存放用户购物车一类的数据,但如今前端拥有两个 storage(local、session),两种数据库(websql、IndexedDB),根本不愁信息存放问题,因此如今基本上 100% 都是在链接上证实客户端的身份。例如登陆以后,服务器给你一个标志,就存在 cookie 里,以后再链接时,都会自动带上 cookie,服务器便分清谁是谁。另外,cookie 还能够用于跟踪一个用户,这就产生了隐私问题,因而也就有了“禁用 cookie”这个选项(然而如今这个时代禁用 cookie 是挺麻烦的事情)。github
现实世界的例子明白了,在计算机中怎么才能设置 cookie 呢?通常来讲,安全起见,cookie 都是依靠 set-cookie
头设置,且不容许 JavaScript 设置。web
Set-Cookie: <cookie-name>=<cookie-value> Set-Cookie: <cookie-name>=<cookie-value>; Expires=<date> Set-Cookie: <cookie-name>=<cookie-value>; Max-Age=<non-zero-digit> Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value> Set-Cookie: <cookie-name>=<cookie-value>; Path=<path-value> Set-Cookie: <cookie-name>=<cookie-value>; Secure Set-Cookie: <cookie-name>=<cookie-value>; HttpOnly Set-Cookie: <cookie-name>=<cookie-value>; SameSite=Strict Set-Cookie: <cookie-name>=<cookie-value>; SameSite=Lax Set-Cookie: <cookie-name>=<cookie-value>; SameSite=None; Secure // Multiple attributes are also possible, for example: Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly
其中 <cookie-name>=<cookie-value>
这样的 kv 对,内容随你定,另外还有 HttpOnly、SameSite 等配置,一条 Set-Cookie
只配置一项 cookie。面试
/
全匹配Secure 和 HttpOnly 是强烈建议开启的。SameSite 选项须要根据实际状况讨论,由于 SameSite 可能会致使即便你用 CORS 解决了跨越问题,依然会由于请求没自带 cookie 引发一系列问题,一开始还觉得是 axios 配置问题,绕了一大圈,然而根本不要紧。
其实由于 Chrome 在某一次更新后把没设置 SameSite
默认为 Lax
,你不在服务器手动把 SameSite
设置为 None
就不会自动带 cookie 了。
参考 MDN,cookie 的发送格式以下(其中 PHPSESSID 相关内容下面会提到):
Cookie: <cookie-list> Cookie: name=value Cookie: name=value; name2=value2; name3=value3 Cookie: PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1
在发送 cookie 时,并不会传上面提到的配置到服务器,由于服务器在设置后就不须要关心这些信息了,只要现代浏览器运做正常,收到的 cookie 就是没问题的。
从 cookie 说到 session,是由于 session 才是真正的“信息”,如上面提到的,cookie 是容器,里面装着 PHPSESSID=298zf09hf012fh2;
,这就是一个 session ID。
不知道 session 和 session id 会不会让你看得有点头晕?
当初 session 的存在就是要为客户端和服务器链接提供的信息,因此我将 session 理解为信息,而 session id 是获取信息的钥匙,一般是一串惟一的哈希码。
接下来分析两个 node.js express 的中间件,理解两种 session 的实现方式。
session 信息能够储存在客户端,如 cookie-session,也能够储存在服务器,如 express-session。使用 session ID 就是把 session 放在服务器里,用 cookie 里的 id 寻找服务器的信息。
对于 cookie-session 库,比较容易理解,其实就是把全部信息加密后塞到 cookie 里。其中涉及到 cookies 库。在设置 session 时其实就是调用 cookies.set,把信息写到 set-cookie 里,再返回浏览器。换言之,取值和赋值的本质都是操做 cookie。
浏览器在接收到 set-cookie 头后,会把信息写到 cookie 里。在下次发送请求时,信息又经过 cookie 原样带回来,因此服务器什么东西都不用存,只负责获取和处理 cookie 里的信息,这种实现方法不须要 session ID。
这是一段使用 cookie-session 中间件为请求添加 cookie 的代码:
const express = require('express') var cookieSession = require('cookie-session') const app = express() app.use( cookieSession({ name: 'session', keys: [ /* secret keys */ 'key', ], // Cookie Options maxAge: 24 * 60 * 60 * 1000, // 24 hours }) ) app.get('/', function(req, res) { req.session.test = 'hey' res.json({ wow: 'crazy', }) }) app.listen(3001)
在经过 app.use(cookieSession())
使用中间件以前,请求是不会设置 cookie 的,添加后再访问(而且在设置 req.session 后,若不添加 session 信息就不必写、也没内容写到 cookie 里),就能看到服务器响应头部新增了下面两行,分别写入 session 和 session.sig:
Set-Cookie: session=eyJ0ZXN0IjoiaGV5In0=; path=/; expires=Tue, 23 Feb 2021 01:07:05 GMT; httponly Set-Cookie: session.sig=QBoXofGvnXbVoA8dDmfD-GMMM6E; path=/; expires=Tue, 23 Feb 2021 01:07:05 GMT; httponly
而后你就能在 DevTools 的 Application 标签看到 cookie 成功写入。session 的值 eyJ0ZXN0IjoiaGV5In0=
经过 base64 解码(不了解 base64 的话能够看这里)便可获得 {"test":"hey"}
,这就是所谓的“将 session 信息放到客户端”,由于 base64 编码并非加密,这就跟明文传输没啥区别,因此请不要在客户端 session 里放用户密码之类的机密信息。
即便现代浏览器和服务器作了一些约定,例如使用 https、跨域限制、还有上面提到 cookie 的 httponly 和 sameSite 配置等,保障了 cookie 安全。可是想一想,传输安全保障了,若是有人偷看你电脑里的 cookie,密码又刚好存在 cookie,那就能无声无息地偷走密码。相反的,只放其余信息或是仅仅证实“已登陆”标志的话,只要退出一次,这个 cookie 就失效了,算是下降了潜在危险。
说回第二个值 session.sig,它是一个 27 字节的 SHA1 签名,用以校验 session 是否被篡改,是 cookie 安全的又一层保障。
既然要储存在服务器,那么 express-session 就须要一个容器 store,它能够是内存、redis、mongoDB 等等等等,内存应该是最快的,可是重启程序就没了,redis 能够做为备选,用数据库存 session 的场景感受很少。
express-session 的源码没 cookie-session 那么简明易懂,里面有一个有点绕的问题,req.session
究竟是怎么插入的?
不关注实现能够跳过这段,有兴趣的话能够跟着思路看看 express-session 的源码。
咱们能够从 .session =
这个关键词开始找,找到:
store.generate
否决这个,容易看出这个是初始化使用的Store.prototype.createSession
这个是根据 req 和 sess 参数在 req 中设置 session 属性,没错,就是你了因而全局搜索 createSession
,锁定 index 里的 inflate
(就是填充的意思)函数。
最后寻找 inflate
的调用点,是使用 sessionID 为参数的 store.get
的回调函数,一切说得通啦——
在监测到客户端送来的 cookie 以后,能够从 cookie 获取 sessionID,再使用 id 在 store 中获取 session 信息,挂到 req.session
,通过这个中间件,你就能顺利地使用 req 中的 session。
那赋值怎么办呢?这就和上面储存在客户端不一样了,上面要修改客户端 cookie 信息,可是对于储存在服务器的状况,你修改了 session 那就是“实实在在地修改”了嘛,不用其余花里胡哨的方法,内存中的信息就是修改了,下次获取内存里的对应信息也是修改后的信息。(仅限于内存的实现方式,使用数据库时仍须要额外的写入)
在请求没有 session id 的状况下,经过 store.generate
建立新的 session,在你写 session 的时候,cookie 能够不改变,只要根据原来的 cookie 访问内存里的 session 信息就能够了。
var express = require('express') var parseurl = require('parseurl') var session = require('express-session') var app = express() app.use( session({ secret: 'keyboard cat', resave: false, saveUninitialized: true, }) ) app.use(function(req, res, next) { if (!req.session.views) { req.session.views = {} } // get the url pathname var pathname = parseurl(req).pathname // count the views req.session.views[pathname] = (req.session.views[pathname] || 0) + 1 next() }) app.get('/foo', function(req, res, next) { res.json({ session: req.session, }) }) app.get('/bar', function(req, res, next) { res.send('you viewed this page ' + req.session.views['/bar'] + ' times') }) app.listen(3001)
首先仍是计算机世界最重要的哲学问题:时间和空间的抉择。
储存在客户端的状况,解放了服务器存放 session 的内存,可是每次都带上一堆 base64 处理的 session 信息,若是量大的话传输就会很缓慢。
储存在服务器相反,用服务器的内存拯救了带宽。
另外,在退出登陆的实现和结果,也是有区别的。
储存在服务器的状况就很简单,若是 req.session.isLogin = true
是登陆,那么 req.session.isLogin = false
就是退出。
可是状态存放在客户端要作到真正的“即时退出登陆”就很困难了。你能够在 session 信息里加上过时日期,也能够直接依靠 cookie 的过时日期,过时以后,就当是退出了。
可是若是你不想等到 session 过时,如今就想退出登陆!怎么办?认真想一想你会发现,仅仅依靠客户端储存的 session 信息真的没有办法作到。
即便你经过 req.session = null
删掉客户端 cookie,那也只是删掉了,可是若是有人曾经把 cookie 复制出来了,那他手上的 cookie 直到 session 信息里的过时时间前,都是有效的。
说“即时退出登陆”有点标题党的意味,其实我想表达的是,你没办法当即废除一个 session,这可能会形成一些隐患。
session 说完了,那么出现频率超高的关键字 token 又是什么?
不妨谷歌搜一下 token 这个词,能够看到冒出来几个(年纪大的人)比较熟悉的图片:密码器。过去网上银行不是只要短信认证就能转帐,还要通过一个密码器,上面显示着一个变更的密码,在转帐时你须要输入密码器中的代码才能转帐,这就是 token 现实世界中的例子。凭借一串码或是一个数字证实本身身份,这事情不就和上面提到的行李问题仍是同样的吗……
其实本质上 token 的功能就是和 session id 如出一辙。你把 session id 说成 session token 也没什么问题(Wikipedia 里就写了这个别名)。
其中的区别在于,session id 通常存在 cookie 里,自动带上;token 通常是要你主动放在请求中,例如设置请求头的 Authorization
为 bearer:<access_token>
。
然而上面说的都是通常状况,根本没有明确规定!
剧透一下,下面要讲的 JWT(JSON Web Token)!他是一个 token!可是里面放着 session 信息!放在客户端,而且能够随你选择放在 cookie 或是手动添加在 Authorization!可是他就叫 token!
我的以为你不能经过存放的位置判断是 token 或是 session id,也不能经过内容判断是 token 或是 session 信息,session、session id 以及 token 都是很意识流的东西,只要你明白他是什么、怎么用就行了,怎么称呼不过重要。
另外在搜索资料时也看到有些文章说 session 和 token 的区别就是新旧技术的区别,好像有点道理。
在 session 的 Wikipedia 页面上 HTTP session token 这一栏,举例都是 JSESSIONID (JSP)、PHPSESSID (PHP)、CGISESSID (CGI)、ASPSESSIONID (ASP) 等比较传统的技术,就像 SESSIONID 是他们的代名词通常;而在研究如今各类平台的 API 接口和 OAuth2.0 登陆时,都是使用 access token 这样的字眼,这个区别着实有点意思。
理解 session 和 token 的联系以后,能够在哪里能看到“活的” token 呢?
打开 GitHub 进入设置,找到 Settings / Developer settings,能够看到 Personal access tokens 选项,生成新的 token 后,你就能够带着它经过 GitHub API,证实“你就是你”。
在 OAuth 系统中也使用了 Access token 这个关键词,写过微信登陆的朋友应该都能感觉到 token 是个什么啦。
Token 在权限证实上真的很重要,不可泄漏,谁拿到 token,谁就是“主人”。因此要作一个 Token 系统,刷新或删除 Token 是必需要的,这样在尽快弥补 token 泄漏的问题。
在理解了三个关键字和两种储存方式以后,下面咱们正式开始说“用户登陆”相关的知识和两种登陆规范——JWT 和 OAuth2.0。
接着你可能会频繁见到 Authentication 和 Authorization 这两个单词,它们都是 Auth 开头,但可不是一个意思,简单来讲前者是验证,后者是受权。在编写登陆系统时,要先验证用户身份,设置登陆状态,给用户发送 token 就是受权。
全称 JSON Web Token(RFC 7519),是的,JWT 就是一个 token。为了方便理解,提早告诉你们,JWT 用的是上面客户端储存的方式,因此这部分可能会常常用到上面提到的名称。
虽然说 JWT 就是客户端储存 session 信息的一种,可是 JWT 有着本身的结构:Header.Payload.Signature
(分为三个部分,用 .
隔开)
{ "alg": "HS256", "typ": "JWT" }
typ 说明 token 类型是 JWT,alg 表明签名算法,HMAC、SHA25六、RSA 等。而后将其 base64 编码。
{ "sub": "1234567890", "name": "John Doe", "admin": true }
Payload 是放置 session 信息的位置,最后也要将这些信息进行 base64 编码,结果就和上面客户端储存的 session 信息差很少。
不过 JWT 有一些约定好的属性,被称为 Registered claims,包括:
最后一部分是签名,和上面提到的 session.sig
同样是用于防止篡改,不过 JWT 把签名和内容组合到一块儿罢了。
JWT 签名的生成算法是这样的:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
使用 Header 里 alg 的算法和本身设定的密钥 secret 编码 base64UrlEncode(header) + "." + base64UrlEncode(payload)
最后将三部分经过 .
组合在一块儿,你能够经过 jwt.io Debugger 形象地看到 JWT 的组成原理:
在验证用户,顺利登陆后,会给用户返回 JWT。由于 JWT 的信息没有加密,因此别往里面放密码,详细缘由在客户端储存的 cookie 中提到。
用户访问须要受权的链接时,能够把 token 放在 cookie,也能够在请求头带上 Authorization: Bearer <token>
。(手动放在请求头不受 CORS 限制,不怕 CSRF)
这样能够用于自家登陆,也能够用于第三方登陆。单点登陆也是 JWT 的经常使用领域。
JWT 也由于信息储存在客户端形成没法让本身失效的问题,这算是 JWT 的一个缺点。
HTTP authentication 是一种标准化的校验方式,不会使用 cookie 和 session 相关技术。请求头带有 Authorization: Basic <credentials>
格式的受权字段。
其中 credentials 就是 Base64 编码的用户名 + :
+ 密码(或 token),之后看到 Basic authentication,意识到就是每次请求都带上用户名密码就行了。
Basic authentication 大概比较适合 serverless,毕竟他没有运行着的内存,没法记录 session,直接每次都带上验证就完事了。
OAuth 2.0(RFC 6749)也是用 token 受权的一种协议,它的特色是你能够在有限范围内使用别家接口,也能够借此使用别家的登陆系统登陆自家应用,也就是第三方应用登陆。(注意啦注意啦,OAuth 2.0 受权流程说不定面试会考哦!)
既然是第三方登陆,那除了应用自己,一定存在第三方登陆服务器。在 OAuth 2.0 中涉及三个角色:用户、应用提供方、登陆平台,相互调用关系以下:
+--------+ +---------------+ | |--(A)- Authorization Request ->| Resource | | | | Owner | | |<-(B)-- Authorization Grant ---| | | | +---------------+ | | | | +---------------+ | |--(C)-- Authorization Grant -->| Authorization | | Client | | Server | | |<-(D)----- Access Token -------| | | | +---------------+ | | | | +---------------+ | |--(E)----- Access Token ------>| Resource | | | | Server | | |<-(F)--- Protected Resource ---| | +--------+ +---------------+
不少大公司都提供 OAuth 2.0 第三方登陆,这里就拿小聋哥的微信举例吧——
通常来讲,应用提供方须要先在登陆平台申请好 AppID 和 AppSecret。(微信使用这个名称,其余平台也差很少,一个 ID 和一个 Secret)
什么是受权临时票据(code)? 答:第三方经过 code 进行获取access_token
的时候须要用到,code 的超时时间为 10 分钟,一个 code 只能成功换取一次access_token
即失效。code 的临时性和一次保障了微信受权登陆的安全性。第三方可经过使用 https 和 state 参数,进一步增强自身受权登陆的安全性。
在这一步中,用户先在登陆平台进行身份校验。
https://open.weixin.qq.com/connect/qrconnect? appid=APPID& redirect_uri=REDIRECT_URI& response_type=code& scope=SCOPE& state=STATE #wechat_redirect
参数 | 是否必须 | 说明 |
---|---|---|
appid | 是 | 应用惟一标识 |
redirect_uri | 是 | 请使用 urlEncode 对连接进行处理 |
response_type | 是 | 填 code |
scope | 是 | 应用受权做用域,拥有多个做用域用逗号(,)分隔,网页应用目前仅填写 snsapi_login |
state | 否 | 用于保持请求和回调的状态,受权请求后原样带回给第三方。该参数可用于防止 csrf 攻击(跨站请求伪造攻击) |
注意一下 scope 是 OAuth2.0 权限控制的特色,定义了这个 code 换取的 token 能够用于什么接口。
正确配置参数后,打开这个页面看到的是受权页面,在用户受权成功后,登陆平台会带着 code 跳转到应用提供方指定的 redirect_uri
:
redirect_uri?code=CODE&state=STATE
受权失败时,跳转到
redirect_uri?state=STATE
也就是失败时没 code。
在跳转到重定向 URI 以后,应用提供方的后台须要使用微信给你的code获取 token,同时,你也能够用传回来的 state 进行来源校验。
要获取 token,传入正确参数访问这个接口:
https://api.weixin.qq.com/sns/oauth2/access_token? appid=APPID& secret=SECRET& code=CODE& grant_type=authorization_code
参数 | 是否必须 | 说明 |
---|---|---|
appid | 是 | 应用惟一标识,在微信开放平台提交应用审核经过后得到 |
secret | 是 | 应用密钥 AppSecret,在微信开放平台提交应用审核经过后得到 |
code | 是 | 填写第一步获取的 code 参数 |
grant_type | 是 | 填 authorization_code,是其中一种受权模式,微信如今只支持这一种 |
正确的返回:
{ "access_token": "ACCESS_TOKEN", "expires_in": 7200, "refresh_token": "REFRESH_TOKEN", "openid": "OPENID", "scope": "SCOPE", "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL" }
获得 token 以后你就能够根据以前申请 code 填写的 scope 调用接口了。
受权做用域(scope) | 接口 | 接口说明 |
---|---|---|
snsapi_base | /sns/oauth2/access_token | 经过 code 换取 access_token 、refresh_token 和已受权 scope |
snsapi_base | /sns/oauth2/refresh_token | 刷新或续期 access_token 使用 |
snsapi_base | /sns/auth | 检查 access_token 有效性 |
snsapi_userinfo | /sns/userinfo | 获取用户我的信息 |
例如获取我的信息就是 GET
https://api.weixin.qq.com/sns...
注意啦,在微信 OAuth 2.0,access_token
使用 query 传输,而不是上面提到的 Authorization。
使用 Authorization 的例子,如 GitHub 的受权,前面的步骤基本一致,在获取 token 后,这样请求接口:
curl -H "Authorization: token OAUTH-TOKEN" https://api.github.com
说回微信的 userinfo 接口,返回的数据格式以下:
{ "openid": "OPENID", "nickname": "NICKNAME", "sex": 1, "province":"PROVINCE", "city":"CITY", "country":"COUNTRY", "headimgurl":"https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46", "privilege":[ "PRIVILEGE1" "PRIVILEGE2" ], "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL" }
在使用 token 获取用户我的信息后,你能够接着用 userinfo 接口返回的 openid,结合 session 技术实如今本身服务器登陆。
// 登陆 req.session.id = openid if (req.session.id) { // 已登陆 } else { // 未登陆 } // 退出 req.session.id = null // 清除 session
总结一下 OAuth2.0 的流程和重点:
OAuth2.0 着重于第三方登陆和权限限制。并且 OAuth2.0 不止微信使用的这一种受权方式,其余方式能够看阮老师的OAuth 2.0 的四种方式。
JWT 和 OAuth2.0 都是成体系的鉴权方法,不表明登陆系统就必定要这么复杂。
简单登陆系统其实就以上面两种 session 储存方式为基础就能作到。
req.session.isLogin = true
的方法标志该 session 的状态为已登陆。{ "exp": 1614088104313, "usr": "admin" }
(就是和 JWT 原理基本同样,不过没有一套体系)
let store = {} // 登陆成功后 store[HASH] = true cookie.set('token', HASH) // 须要鉴权的请求钟 const hash = cookie.get('token') if (store[hash]) { // 已登陆 } else { // 未登陆 } // 退出 const hash = cookie.get('token') delete store[hash]
如下列出本文重点:
set-cookie
请求头设置