存储到 Cookie
一、认识
在 JWT(JSON Web Token)
认证模式下,主要有两种常见的 Token
存储方式:一种是服务端将 Token
存储在 Cookie
中,另一种是服务端将 Token
返回给前端,由前端将 Token
存储在 LocalStorage
或 SessionStorage
中。这两种方式各有利弊,适用于不同的场景。
当我们选择 存储到 Cookie
方案后, 当用户登录成功后,服务端会生成一个 JWT
,并将其设置在响应的 Set-Cookie
头中,并返回给客户端。Token
存储在 Cookie
中,并带有 HttpOnly
和 Secure
属性(如果是 HTTPS
网站),这些属性可以增强安全性。浏览器会在每次发送请求时自动将 Cookie
中的 Token
附加在请求头中。
存储到 Cookie
浏览器会自动将存储在 Cookie
中的 Token
随请求发送给服务器,无需前端开发人员手动处理。但是,有如下缺点和挑战:
-
CSRF(Cross-Site Request Forgery)
攻击: 由于Cookie
会自动随请求发送给目标域名,所以当用户登录并拥有Cookie
时,恶意网站可以诱使用户在不知情的情况下发送请求,造成CSRF
攻击。如何防止恶意网站利用用户的Cookie
发起伪造请求? 使用SameSite Cookie
属性(设置为Strict
或Lax
),限制Cookie
在跨站请求中是否发送。Strict
会阻止所有跨站请求发送Cookie
,而Lax
允许常见的导航请求(如链接点击)发送Cookie
。使用Anti-CSRF Token
:前端应用每次提交表单时,生成并携带一个随机的CSRF Token
,服务器验证该Token
来确保请求来源的合法性。请求类型限制:使用POST
、PUT
、DELETE
等修改数据的请求,而不是GET
请求。GET
请求本身不会修改服务器状态,攻击者难以伪造对服务器的敏感请求。 -
跨域问题:
Cookie
默认情况下不能跨域传递。为了支持跨域认证,必须设置SameSite=None
和Secure
属性,这样会让Cookie
可以跨域,但也增加了潜在的安全风险。如何在安全的前提下实现跨域认证? 配置CORS
(跨域资源共享):通过设置服务端的Access-Control-Allow-Origin
和Access-Control-Allow-Credentials
等头部,来确保跨域时Cookie
能正确地发送和接收。跨域配置Cookie
:设置Cookie
的SameSite=None
和Secure
属性,确保它在跨域情况下也能发送。SameSite=None
允许跨域请求时Cookie
被发送,Secure
确保Cookie
只有在HTTPS
环境下传输。 -
Token
过期和刷新:Token
在Cookie
中会随着每次请求自动发送,而Token
通常是有过期时间的。当Token
过期后,用户需要重新登录。如何确保Token
的过期机制不会影响用户体验,同时避免过期Token
的滥用? 使用Refresh Token
:Refresh Token
是一个长期有效的Token
,在Access Token
过期时,可以在服务端通过Refresh Token
用来获取新的Access Token
。这样,用户不需要频繁登录。通过滑动过期时间:在每次用户访问时,服务器可以延长Token
的有效期,确保用户在活跃时不会频繁过期。
二、工作
JWT Token
登录认证并实现 Token
无感刷新 主要的流程可以分为以下几个步骤:
1. 用户登录认证: 前端提交用户名和密码,服务器生成 Access Token
和 Refresh Token
, 将 Access Token
存储在 Cookie
中, 并加上 HttpOnly
和 Secure
保护,防止 JS
获取和防止中间人攻击。将 Refresh Token
存储到 Redis
中, Refresh Token
通常会设置较长的有效期(如 7 天)。
-
Secure
:确保Cookie
只能通过HTTPS
协议传输,增强安全性。 -
HttpOnly
:防止客户端JavaScript
直接访问Cookie
,从而降低XSS
攻击的风险。 -
SameSite=None
:允许跨域请求时发送Cookie
,解决跨子域的Cookie
问题。 -
Domain=.example.com
:确保Cookie
对所有子域(如app.example.com
、portal.example.com
)有效。
2. 用户后续请求验证: 每次前端发起 API
请求时,浏览器会自动带上 Access Token
(通过 Cookie
)。服务端进行请求验证, 如果 Access Token
验证成功,后端处理请求。如果 Access Token
过期,服务端会尝试从 Redis
获取 Refresh Token
并验证其有效性。如果 Refresh Token
有效,服务端会尝试使用 Refresh Token
来刷新 Access Token
并返回新的 Access Token
。如果 Refresh Token
无效或过期,则返回 401
错误,要求前端重新登录。
3. 用户退出登录: 用户登出时,清除 Cookie
中的 Access Token
和 Redis
中的 Refresh Token
。
注意: 如果认证服务和应用处于同一顶级域下, 可以通过设置 Domain=.example.com
来共享 Cookie
,使得 Access Token
可以跨子域共享。
三、目录
/myapp
├── .env
├── app.js
├── config.js
├── routes.js
├── utils.js
├── redisClient.js
四、准备
安装依赖
npm install koa koa-router koa-bodyparser jsonwebtoken koa-redis redis dotenv
五、实现
5.1 .env
JWT_SECRET=your_jwt_secret_key
JWT_ACCESS_EXPIRATION=15m # Access Token 过期时间
JWT_REFRESH_EXPIRATION=7d # Refresh Token 过期时间
REDIS_HOST=localhost
REDIS_PORT=6379
5.2 config.js
require('dotenv').config();
module.exports = {
jwtSecret: process.env.JWT_SECRET,
jwtAccessExpiration: process.env.JWT_ACCESS_EXPIRATION,
jwtRefreshExpiration: process.env.JWT_REFRESH_EXPIRATION,
redisHost: process.env.REDIS_HOST,
redisPort: process.env.REDIS_PORT,
};
5.2 utils.js
const jwt = require('jsonwebtoken');
const config = require('./config');
// 生成 Access Token
function generateAccessToken(payload) {
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtAccessExpiration });
}
// 生成 Refresh Token
function generateRefreshToken(payload) {
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtRefreshExpiration });
}
// 验证 Token
function verifyToken(token) {
try {
return jwt.verify(token, config.jwtSecret);
} catch (err) {
return null;
}
}
module.exports = {
generateAccessToken,
generateRefreshToken,
verifyToken,
};
5.3 redisClient.js
const Redis = require('redis');
const config = require('./config');
const redisClient = Redis.createClient({
host: config.redisHost,
port: config.redisPort,
});
redisClient.on('error', (err) => {
console.log('Redis error:', err);
});
module.exports = redisClient;
5.4 routes.js
const Router = require('koa-router');
const { generateAccessToken, generateRefreshToken, verifyToken } = require('./utils');
const redisClient = require('./redisClient');
const router = new Router();
// 用户登录
router.post('/login', async (ctx) => {
const { username, password } = ctx.request.body;
// 假设的用户名密码验证逻辑
if (username === 'test' && password === 'password') {
const userPayload = { username };
// 生成 Access Token 和 Refresh Token
const accessToken = generateAccessToken(userPayload);
const refreshToken = generateRefreshToken(userPayload);
// 将 Refresh Token 存储到 Redis,设置 7 天有效期
await redisClient.setex(userPayload.username, 60 * 60 * 24 * 7, refreshToken);
// 将 Access Token 设置到 Cookie 中
ctx.cookies.set('accessToken', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // 生产环境才启用 Secure 标志
maxAge: 1000 * 60 * 15, // 设置过期时间为 15 分钟
});
ctx.body = { message: 'Login successful' };
} else {
ctx.status = 401;
ctx.body = { message: 'Invalid username or password' };
}
});
// 自动验证并刷新 Access Token
router.all('/protected/*', async (ctx, next) => {
const accessToken = ctx.cookies.get('accessToken');
if (!accessToken) {
ctx.status = 401;
ctx.body = { message: 'No access token provided' };
return;
}
// 验证 Access Token 是否有效
const payload = verifyToken(accessToken);
if (payload) {
// 如果 Access Token 有效,继续处理请求
await next();
} else {
// 如果 Access Token 过期,尝试通过 Refresh Token 自动刷新
const refreshToken = await redisClient.get(payload.username);
if (!refreshToken) {
ctx.status = 401;
ctx.body = { message: 'No valid refresh token found' };
return;
}
const refreshPayload = verifyToken(refreshToken);
if (!refreshPayload) {
ctx.status = 401;
ctx.body = { message: 'Invalid refresh token' };
return;
}
// 使用 Refresh Token 生成新的 Access Token
const newAccessToken = generateAccessToken(refreshPayload);
// 更新 Cookie 中的 Access Token
ctx.cookies.set('accessToken', newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 1000 * 60 * 15, // 设置新的过期时间
});
// 继续处理请求
await next();
}
});
// 登出,删除 Redis 中的 Refresh Token 和 Cookie 中的 Access Token
router.post('/logout', async (ctx) => {
const { username } = ctx.request.body;
// 删除 Redis 中的 Refresh Token
await redisClient.del(username);
// 清除 Cookie 中的 Access Token
ctx.cookies.set('accessToken', null);
ctx.body = { message: 'Logged out successfully' };
});
module.exports = router;
5.5 app.js
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const config = require('./config');
const router = require('./routes');
const app = new Koa();
// 中间件
app.use(bodyParser());
// 路由
app.use(router.routes()).use(router.allowedMethods());
// 启动服务
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});