在深入讨论之前,我们需要了解会话是什么以及会话身份验证如何工作。
正如我们所知,HTTP
请求是无状态的,这意味着当我们发送登录请求时,并且我们有有效的用户名和密码,没有默认机制可以知道我们是发送下一个请求的同一个人。为了解决这个问题,换句话说,使请求有状态,有一些常用的方法,如Cookie
、隐藏表单字段、URL
参数、浏览器存储、JWT
和 Session
。在本文中,我们将重点关注Session
。
Session
是存储在服务器上的数据。每个客户端都会获得一个与服务器上的数据相关的唯一标识符 。客户端必须在每个请求上发送此唯一标识符,以便我们知道谁发送了此请求。该标识符可以在 cookie
或 URL
参数中发送。
下面是使用express-session
和express
创建session
的简单示例:
const app = require('express')();
const session = require('express-session');
app.use(require('cookie-parser')());
app.use(require('body-parser').json());
app.use(session({
secret: 'secret',
cookie: { maxAge: 60000 },
name: 'sessionId'
}));
app.get('/', (req, res) => {
res.send('ping');
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
当第一次发送请求时,express-session
中间件会创建一个新的唯一标识符,并将其设置为 cookie
后再将其存储在某处(在本例中为内存,但我们也可以传递自定义存储)。在会话中间件的选项中,我们使用了sessionId
存储此唯一标识符的密钥名称。现在,如果我们发送请求,我们会看到如下内容:
浏览器现在设置此 cookie
并自动存储它以供进一步请求。如果我们发送包含有效会话的请求(该会话存在于我们的会话存储中 - 在本例中为内存),在响应中获取标头的标头就不再包含Set-Cookie
字段了:
当用户登录时,我们可以将用户信息存储在 cookie
中(序列化),也可以将其存储在数据库中并将数据与sessionId
进行绑定. 让我们使用 Map
作为我们的简单数据库:
const db = new Map();
app.get('/me', (req, res) => {
const user = db.get(req.sessionID);
res.json({ mySessionId: req.sessionID, me: user ? user : 'anonymous' });
});
const users = [{ name: 'leo', age: 19 }, { name: 'joe', age: 20 }];
app.post('/login', (req, res) => {
const { name } = req.body;
const user = users.find(u => u.name === name);
if (user) {
db.set(req.sessionID, user);
res.send('ok');
} else {
res.send('try again');
}
});
在本例中,我们使用的是express-session
。可以看到我们向express-session
中间件传递了一个secret
值。这个secret
用于签署我们的 cookie
的值。它只是意味着我们确信是我们生成了sessionId
。
session
示例:
sessionId=s%3AL6j4T8hBwMk1ulJqGoisZbAxUOkOuQqP.x5UxPQEtKrj3sWrIy6S01CQRjAtp4biVs4H2zgqmSs
第一部分:s%3A
简单来说:s:
这是一个前缀,表明我们的 cookie
会话已签名!
第二部分:L6j4T8hBwMk1ulJqGoisZbAxUOkOuQqP
这是我们的sessionId
,我们在数据库中使用它来关联数据。
第三部分:x5UxPQEtKrj3sWrIy6S01CQRjAtp4biVs4H2zgqmSs
这是签字部分。我们使用我们的secret
生成了此文本,因此我们可以确定此 cookie
是由我们生成的。
我们可以简单地重新生成这个标志并检查它是否有效:
const crypto = require('crypto');
const secret = 'secret';
const sessionId = 'L6j4T8hBwMk1ulJqGoisZbAxUOkOuQqP';
const hmac = crypto.createHmac('sha256', secret);
hmac.update(sessionId);
const signature = hmac.digest('base64').replace(/\=+$/, '');
console.log(signature); // x5UxPQEtKrj3sWrIy6S01CQRjAtp4biVs4H2zgqmSs
在会话固定攻击中,攻击者劫持有效的用户会话。我们说过,我们对 cookie
进行签名是为了确保没有人可以劫持其他用户的有效会话。但是,如果攻击者拥有自己的有效会话并尝试将其与另一个用户关联怎么办?在这种情况下,他可以代表受害者采取行动。
当我们没有在 Login
之类的操作上生成新的 sessionIds
(唯一标识符)时,就会出现问题。
通过会话固定,攻击者可以劫持有效的用户会话,了解此漏洞并防范它绝对重要。
其中一种情况是攻击者可以物理访问计算机。作为攻击者,我们可以选择一台共享计算机,打开某个已经登录过的网页,然后复制session
后发送请求。
sessionId
在有些网站在请求中作为 URL
参数传递。在这种情况下,如果攻击者使用其 URL
参数提供登录页面的链接,sessionId
则有可能被利用。
主要解决方案非常简单,通过这样做,始终可以确保不会发生此会话覆盖!
让我们改变代码:
app.post('/login', (req, res) => {
const { name } = req.body;
req.session.regenerate(err => {
if (err) {
res.send('error');
} else {
const user = users.find(u => u.name === name);
if (user) {
db.set(req.sessionID, user);
res.send('ok');
} else {
res.send('try again');
}
}
});
});
我们可以使用regenerate
函数,以便每次有人想要登录时分配一个新的会话。无论我们是否传递会话cookie
,它都会生成一个新的会话Id
并将其发送到客户端中。
当我们使用HTTP Only
时,这意味着只有服务器可以通过Set-Cookie
标头设置cookie
,而客户端(浏览器JavaScript
)无法更改它。所以即使我们的应用存在XSS
漏洞,攻击者也无法更改sessionId
(cookie
)。
会话固定可以与 XSS
攻击结合使用,从而更加有效,因此如果我们担心会话固定,那么认真对待 XSS
攻击确实有意义。
会话过期时间应该符合我们的应用程序特定要求,如果我们更关心安全性,那么它应该更短,反之亦然。
注销时,我们必须正确销毁现有会话及其与任何数据的关联。否则,这些会话可以在注销后使用。(仅从客户端浏览器中删除 cookie
是不够的!)