之前的文章,现在实战有了更深的理解,可能还有一些错误之处,后续更改 【项目问题】token存储在哪里以及localStorage的跨域问题 - 掘金 (juejin.cn)
需求
使用koa+lowdb服务端框架与React实现一个登录页面,需要进行csrf攻击防御和token验证
登录流程
- 用户第一次登录时,向服务端发送
login请求 - 服务端处理
login请求,判断用户名密码是否正确。如果正确,使用密钥生成jwt_token - 服务端将token放在response的cookie中发送给用户
❗token发送给客户端的最常见方式是通过HTML表单的隐藏字段。服务端在生成表单页时,将token放入表单的隐藏字段中,确保每次表单提交时都携带token。这里直接放在了token中,且并没有设置http-only,不够安全
router.post('/api/login', async (ctx) => {
const { username, password } = ctx.request.body;
const user = userdb.data.users.find(user => user.username === username && user.password === password);
if (user) {
const token = jwt.sign({ username }, secret, { expiresIn: '1h' }); //使用jwt
const csrfToken = csrfTokens.create(csrfSecret);
ctx.cookies.set('token', token, { httpOnly: false });
ctx.cookies.set('csrfToken', csrfToken,{ path: '/', httpOnly: false });
ctx.body = { token};
} else {
ctx.status = 401; // 未授权
ctx.body = { message: 'Invalid credentials' };
}
});
- 用户收到响应后从cookie中获取token(如果cookie设置了http-only则无法获取),将token放入localstorage中,路由页面通过判断localstorage有无token来进行页面跳转
const isAuthenticated = () => {
return !!localStorage.getItem('jwt_token'); // 检查 JWT token 是否存在
};
<Route
path="/index"
element={
isAuthenticated() ? (
<div className="container">
<Hello/>
</div>
) : (
<Navigate to="/login" />
)
}
/>
- 后续登录时,cookie会随着请求发送到服务端,服务端获取请求cookie中的token并使用密钥进行验证,如果验证成功则登录成功
const authMiddleware = async (ctx, next) => {
const token = ctx.cookies.get('token');
const csrfToken = ctx.cookies.get('csrfToken');
if (!token) {
ctx.status = 401;
ctx.body = { message: 'No token provided' };
return;
}
const decoded = jwt.verify(token, secret);
if (!csrfTokens.verify(csrfSecret, csrfToken)) {
ctx.status = 403;
ctx.body = { message: 'Invalid CSRF token' };
return;
}
ctx.state.user = decoded;
await next();
};
服务端验证token
除了从cookie中获取token进行验证,还有其他方法
请求头中获取
- 用户登录时将token放入请求头中
const handleLogin = async () => {
try {
const csrfToken = getCsrfToken();
const response = await axios.post('http://localhost:3001/api/login', {
username: DOMPurify.sanitize(username),
password: DOMPurify.sanitize(password),
}, {
headers: {
'CSRF-Token': csrfToken, // CSRF 令牌
'Authorization': `Bearer ${localStorage.getItem('jwt_token')}` // JWT 令牌
},
withCredentials: true, //自动携带cookie
});
const { token } = response.data;
localStorage.setItem('jwt_token', token);
localStorage.setItem('username', username);
try {
await TodoManager.getTodos(); // 确保这里没有抛出异常
console.log('Fetched todos successfully');
} catch (error) {
console.error('Failed to fetch todos:', error);
}
navigate('/hello');
} catch (error) {
message.error('登录失败');
}
};
- 服务端从请求头中获取token并进行验证
const authMiddleware = async (ctx, next) => {
const token = ctx.headers['authorization'] ? ctx.headers['authorization'].split(' ')[1] : null;
const csrfToken = ctx.headers['csrf-token'];
if (!token) {
ctx.status = 401;
ctx.body = { message: 'No token provided' };
return;
}
try {
const decoded = jwt.verify(token, secret);
if (!csrfTokens.verify(csrfSecret, csrfToken)) {
ctx.status = 403;
ctx.body = { message: 'Invalid CSRF token' };
return;
}
ctx.state.user = decoded;
await next();
} catch (err) {
ctx.status = 401;
ctx.body = { message: 'Invalid token' };
}
};
csrf_token验证
-
在用户会话初始化时生成一个唯一的 CSRF token:
- 将该 token 存储在服务器端会话中(如果使用会话存储)。
- 将 token 发送给客户端,并保存在客户端的 cookie 中。
-
在每个请求中,客户端发送 token:
- 在请求头中添加 CSRF token。
- 同时,cookie 也会自动包含 CSRF token。
-
服务器端验证:
- 服务器从 cookie 和请求头中分别读取 CSRF token。
- 比较这两个 token 是否一致。
- 验证 token 是否为服务器生成的合法 token。
const authMiddleware = async (ctx, next) => {
const token = ctx.headers['authorization'] ? ctx.headers['authorization'].split(' ')[1] : null;
const csrfTokenFromHeader = ctx.headers['csrf-token'];
const csrfTokenFromCookie = ctx.cookies.get('csrfToken');
if (!token) {
ctx.status = 401;
ctx.body = { message: 'No token provided' };
return;
}
try {
const decoded = jwt.verify(token, secret);
if (!csrfTokenFromHeader || csrfTokenFromHeader !== csrfTokenFromCookie) {
ctx.status = 403;
ctx.body = { message: 'Invalid CSRF token' };
return;
}
ctx.state.user = decoded; // 保存解码后的用户信息
await next();
} catch (err) {
ctx.status = 401;
ctx.body = { message: 'Invalid token' };
}
};
csrf补充
CSRF 攻击原理
-
用户登录受信任网站 A:
- 用户在浏览器中访问并登录网站 A,浏览器保存了网站 A 的会话 cookie。
-
攻击者构造恶意请求:
- 攻击者诱导用户访问攻击者控制的恶意网站 B。
- 恶意网站 B 构造一个针对网站 A 的请求,例如发起一个数据修改请求。
-
浏览器自动携带 cookie:
- 用户点击恶意链接或加载恶意网站 B 后,浏览器会自动携带网站 A 的会话 cookie,发送请求到网站 A。
-
网站 A 误认为是合法用户请求:
- 由于请求包含合法的会话 cookie,网站 A 误以为这是用户自己发起的请求,从而执行了攻击者的恶意操作。
CSRF token 防御机制
CSRF token 是一种独特的、不可预测的字符串,通常在用户会话初始化时生成,并在随后的每个请求中验证。以下是 CSRF token 的工作原理:
-
生成 CSRF token:
- 当用户访问网站并创建会话时,服务器生成一个唯一的 CSRF token,并将其存储在服务器端(例如,存储在用户的会话中)。
- 服务器同时将 CSRF token 发送给客户端,通常通过设置
HttpOnlycookie 或在 HTML 中嵌入一个隐藏字段或meta标签。
-
客户端发送请求时包含 CSRF token:
- 在客户端发送请求时,JavaScript 从
meta标签、隐藏字段或其他存储中读取 CSRF token,并将其添加到请求头或请求体中。
- 在客户端发送请求时,JavaScript 从
-
服务器验证 CSRF token:
- 服务器接收到请求后,从请求头或请求体中提取 CSRF token,并与存储在服务器端的 token 进行比较。
- 如果 CSRF token 验证成功,则请求被认为是合法的;否则,拒绝该请求。
CSRF token 的防御原理
CSRF token 防御 CSRF 攻击的原理在于攻击者无法获得受害者的 CSRF token。即使攻击者能够诱导受害者的浏览器发送请求,攻击者也无法知道或猜测正确的 CSRF token。因为:
- 唯一性:每个会话的 CSRF token 都是唯一的,且通常是随机生成的,难以被猜测。
- 不可预测性:CSRF token 的生成算法使得它无法被预测,即使攻击者知道某个 token 也无法推测出其他 token。
- 双向验证:服务器同时验证会话 cookie 和 CSRF token,确保请求确实来自合法用户。