背景Cookie配置项读写Cookie的方式服务端sessiontoken编码方式防篡改JWTrefresh tokenSSOCrypto.js加解密过程对称加密算法Crypto.js使用AES方式加密原文链接🔗
背景
因为HTTP是无状态的,所以HTTP 请求方和响应方间无法维护状态,都是一次性的,它不知道前后的请求都发生了什么。
在学校或公司,入学入职那一天起,会录入你的身份、账户信息,然后给你发个卡,今后在园区内,你的门禁、打卡、消费都只需要刷这张卡。
前端的存储方式
- 挂到全局变量上,但这是个「体验卡」,一次刷新页面就没了
- 存到 cookie、localStorage 等里,这属于「会员卡」,无论怎么刷新,只要浏览器没清掉或者过期,就一直拿着这个状态。
Cookie
cookie 也是前端存储的一种,但相比于 localStorage 等其他方式,借助 HTTP 头、浏览器能力,cookie 可以做到前端无感知
- 在提供标记的接口,通过 HTTP 返回头的 Set-Cookie 字段,直接「种」到浏览器上
- 浏览器发起请求时,会自动把 cookie 通过 HTTP 请求头的 Cookie 字段,带给接口
配置项
配置:Domain / Path【空间范围】
你不能拿欢乐谷的票进迪士尼。
Domain属性指定浏览器发出 HTTP 请求时,哪些域名要附带这个 Cookie。如果没有指定该属性,浏览器会默认将其设为当前 URL 的一级域名,比如 www.example.com 会设为 example.com,而且以后如果访问example.com的任何子域名,HTTP 请求也会带上这个 Cookie。如果服务器在Set-Cookie字段指定的域名,不属于当前域名,浏览器会拒绝这个 Cookie。
Path属性指定浏览器发出 HTTP 请求时,哪些路径要附带这个 Cookie。只要浏览器发现,Path属性是 HTTP 请求路径的开头一部分,就会在头信息里面带上这个 Cookie。比如,PATH属性是/,那么请求/docs路径也会包含该 Cookie。当然,前提是域名必须一致。
配置:Expires / Max-Age【时间范围】
你用过卡了就不能再进入迪士尼
Expires属性指定一个具体的到期时间,到了指定时间以后,浏览器就不再保留这个 Cookie。它的值是 UTC 格式。如果不设置该属性,或者设为null,Cookie 只在当前会话(session)有效,浏览器窗口一旦关闭,当前 Session 结束,该 Cookie 就会被删除。另外,浏览器根据本地时间,决定 Cookie 是否过期,由于本地时间是不精确的,所以没有办法保证 Cookie 一定会在服务器指定的时间过期。
Max-Age属性指定从现在开始 Cookie 存在的秒数,比如60 * 60 * 24 * 365(即一年)。过了这个时间以后,浏览器就不再保留这个 Cookie。
如果同时指定了Expires和Max-Age,那么Max-Age的值将优先生效。
如果Set-Cookie字段没有指定Expires或Max-Age属性,那么这个 Cookie 就是 Session Cookie,即它只在本次对话存在,一旦用户关闭浏览器,浏览器就不会再保留这个 Cookie。
配置:Secure / HttpOnly【使用方式】
必须给这个卡绑定了身份证才能进入迪士尼
Secure属性指定浏览器只有在加密协议 HTTPS 下,才能将这个 Cookie 发送到服务器。另一方面,如果当前协议是 HTTP,浏览器会自动忽略服务器发来的Secure属性。该属性只是一个开关,不需要指定值。如果通信是 HTTPS 协议,该开关自动打开。
HttpOnly属性指定该 Cookie 无法通过 JavaScript 脚本拿到,主要是Document.cookie属性、XMLHttpRequest对象和 Request API 都拿不到该属性。这样就防止了该 Cookie 被脚本读到,只有浏览器发出 HTTP 请求时,才会带上该 Cookie。
读写Cookie的方式
HTTP头对Cookie的读写
HTTP返回的一个
Set-Cookie
头用于向浏览器写入「一条(且只能是一条)」cookie,格式为 cookie 键值 + 配置键值。Set-Cookie: username=jimu; domain=jimu.com; path=/blog; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly
HTTP 请求的 Cookie 头用于浏览器把符合当前「空间、时间、使用方式」配置的所有 cookie 一并发给服务端。因为由浏览器做了筛选判断,就不需要归还配置内容了,只要发送键值就可以。
Cookie: username=jimu; height=180; weight=80
前端对 cookie 的读写
前端可以创建cookie,服务端创建的 cookie 没加
HttpOnly
,可以修改服务端给的 cookiedocument.cookie = 'username=jimu; domain=jimu.com; path=/blog; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly'; console.log(document.cookie);// username=jimu; height=180; weight=80
服务端session
- 浏览器登录发送账号密码,服务端查用户库,校验用户
- 服务端把用户登录状态存为 Session,生成一个 sessionId
- 通过登录接口返回,把 sessionId set 到 cookie 上
- 此后浏览器再请求业务接口,sessionId 随 cookie 带上
- 服务端查 sessionId 校验 session
- 成功后正常做业务处理,返回结果
node.js
下的Session
处理,使用express-session
中间件处理,主要实现了:- 封装了对cookie的读写操作,并提供配置项配置字段、加密方式、过期时间等。
- 封装了对session的存取操作,并提供配置项配置session存储方式(内存/redis)、存储规则等。
- 给req提供了session属性,控制属性的set/get并响应到cookie和session存取上,并给req.session提供了一些方法。
token
session 的维护给服务端造成很大困扰,我们必须找地方存放它,又要考虑分布式的问题,甚至要单独为了它启用一套 Redis 集群。
可以将用户信息全部打包到cookie中,服务端不用存了,只要检验cookie有效性
- 用户登录,服务端校验账号密码,获得用户信息
- 把用户信息、token 配置编码成 token,通过 cookie set 到浏览器
- 此后用户请求业务接口,通过 cookie 携带 token
- 接口校验 token 有效性,进行正常业务接口处理
编码方式
- base64
- node的
cookie-session - npm
防篡改
给 token 加签名,来识别 token 是否被篡改过,cdd 虽然能伪造出用户信息的base64,但伪造不出 sig 的内容,因为他不知道 secret。
JWT
但上面的做法额外增加了 cookie 数量,数据本身也没有规范的格式。
JSON Web Token (JWT) 是一个开放标准,定义了一种传递 JSON 信息的方式。这些信息通过数字签名确保可信。
refresh token
access token:业务接口用来鉴权的 token
越是权限敏感的业务,我们越希望 access token 有效期足够短,以避免被盗用。但过短的有效期会造成 access token 经常过期,办法有
- 让用户重新登录获取新 token,显然不够友好
- 再来一个 token,一个专门生成 access token 的 token,我们称为 refresh toke
- access token 用来访问业务接口,由于有效期足够短,盗用风险小,也可以使请求方式更宽松灵活
- refresh token 用来获取 access token,有效期可以长一些,通过独立服务和严格的请求方式增加安全性;由于不常验证,也可以如前面的 session 一样处理
SSO
SSO 是英文 Single Sign On 的缩写,翻译过来就是单点登录。顾名思义,它把两个及以上个产品中的用户登录逻辑抽离出来,达到只输入一次用户名密码,就能同时登录多个产品的效果。
- 在 SSO 域下,SSO 不是通过接口把 ticket 直接返回,而是通过一个带 code 的 URL 重定向到系统 A 的接口上,这个接口通常在 A 向 SSO 注册时约定
- 浏览器被重定向到 A 域下,带着 code 访问了 A 的 callback 接口,callback 接口通过 code 换取 ticket
- 这个 code 不同于 ticket,code 是一次性的,暴露在 URL 中,只为了传一下换 ticket,换完就失效
- callback 接口拿到 ticket 后,在自己的域下 set cookie 成功
- 在后续请求中,只需要把 cookie 中的 ticket 解析出来,去 SSO 验证就好访问 B 系统也是一样
Crypto.js加解密过程
HTTPS(443)在HTTP(80)的基础上加入了,SSL(Secure Sockets Layer 安全套接层)协议,SSL依靠证书来验证服务器的身份,并为浏览器和服务器之间的通信加密。传输前用公钥加密,服务器端用私钥解密。
对于使用http协议的web前端的加密,只能防君子不能防小人。前端是完全暴露的,包括你的加密算法。知道了加密算法,密码都是可以解出的,只是时间问题。而为了保证数据库中存储的密码更安全,则需要在后端用多种单向(非对称)加密手段混合进行加密存储。
对称加密算法
- 前端使用 encrypted = encrypt(password+key),
- 后端使用 password = decrypt(encrypted +key)
- 前端只传输密码与key加密后的字符串encrypted ,这样即使请求被拦截了,也知道了加密算法,但是由于缺少key所以很难解出明文密码。所以这个key很关键。
- 这个key是由后端控制生成与销毁的,用完即失效,所以即使可以模拟用加密后的密码来发请求模拟登录,但是key已经失效了,后端还是验证不过的。
- 如果真要防,可以将加密算法的js文件进行压缩加密,不断更新的手段来使js文件难以获取,让黑客难以获取加密算法。变态的google就是这么干的,自己实现一个js虚拟机,通过不断更新加密混淆js文件让加密算法难以获取。这样黑客不知道加密算法就无法解出了。
常用的对称加密算法有 DES、3DES(TripleDES)、AES、RC2、RC4、RC5和Blowfis
Crypto.js使用AES方式加密
/** * Name: encode.js * Instro: * 1.加密: * - 对sso返回来的json,转化成base64 * - 根据制定加密算法类型和key创建加密对象 * - 加密对象对base64转化成buffer进行加密 * - buffer转化成字符串后,进行正则替换,得到结果 * 2.解密: * - 放入加密结果 * - 正则替换 * - 基于key创建加密对象 * - base64反解,获取结果 */ const Crypto = require('crypto'); const CryptoJs = require('crypto-js'); class SecreteAlgorithm { constructor(key, cipher, json) { this.key = key; this.cipher = cipher; this.json = json; } // buffer 加密 base64encode(s, urlsafe) { if (!Buffer.isBuffer(s)) { s = typeof Buffer.from === 'function' ? Buffer.from(s) : new Buffer(s); } var encode = s.toString('base64'); if (urlsafe) { encode = encode.replace(/\+/g, '-').replace(/\//g, '_'); } return encode; }; // buffer解密 base64decode(encodeStr, urlsafe, encoding) { if (urlsafe) { encodeStr = encodeStr.replace(/\-/g, '+').replace(/_/g, '/'); } var buf = typeof Buffer.from === 'function' ? Buffer.from(encodeStr, 'base64') : new Buffer(encodeStr, 'base64'); if (encoding === 'buffer') { return buf; } return buf.toString(encoding || 'utf8'); }; // 1.加密信息 encrypt() { const word = CryptoJs.enc.Utf8.parse(JSON.stringify(this.json)); const data = CryptoJs.enc.Base64.stringify(word) const cipherW = Crypto.createCipher(this.cipher, this.key); const text = cipherW.update(data, 'utf-8'); const pad = cipherW.final(); const encode = Buffer.concat([text, pad]); const result = this.base64encode(encode, true); return result; } // 2.解密信息 decrypt(data) { if(!this.key) { const keys = [this.key]; for (let i = 0; i < keys.concat.length; i++) { const value = decrypt(data, keys[i]); if(value !== false) return { value, index: i}; } return false; } try { const deRes = this.base64decode(data, true, 'buffer'); const ciphers = Crypto.createDecipher(this.cipher, this.key); const text = ciphers.update(deRes, 'utf-8'); const pad = ciphers.final(); const decode = Buffer.concat([text, pad]); const re = decode.toString(); const result = CryptoJs.enc.Base64.parse(re).toString(CryptoJs.enc.Utf8); return result; }catch(err) { console.log('err', err); return false; } } } // 赋值 // key(盐值), cipher为固定值 const key = 'FE_Cock.cn'; const cipher = 'aes-256-cbc'; // json为sso返回值 const json = { "sso_cock#user":"xxxx", "_expire":1635413382104, "_maxAge":172800000 }; // 使用方法 const scr = new SecreteAlgorithm(key, cipher, json); const encode = scr.encrypt(); const tmp = 'fnH5wvRhO4eUS6-6pcu2mieOs8eTj3_NQbRuB9K87Gytg9UP-1p3bogIvtC3ikQ73lGyWfnh1rHhVM2z-sXkX3hgoO8p1LlTFpSdzr3HMVtJ08bZNRa1AwmFUXv7xJKMpZibWCjKoaRrF3dSMdSiwA=='; const decode = scr.decrypt(tmp); // 调试方法 console.log('\x1B[45m Final Result: \x1B[0m', encode); console.log('\x1B[46m Decode Result: \x1B[0m', decode);