跳到主要内容

存储到 Cookie

2025年01月07日
柏拉文
越努力,越幸运

一、认识


JWT(JSON Web Token) 认证模式下,主要有两种常见的 Token 存储方式:一种是服务端将 Token 存储在 Cookie 中,另一种是服务端将 Token 返回给前端,由前端将 Token 存储在 LocalStorageSessionStorage 中。这两种方式各有利弊,适用于不同的场景。

当我们选择 存储到 Cookie 方案后, 当用户登录成功后,服务端会生成一个 JWT,并将其设置在响应的 Set-Cookie 头中,并返回给客户端。Token 存储在 Cookie 中,并带有 HttpOnlySecure 属性(如果是 HTTPS 网站),这些属性可以增强安全性。浏览器会在每次发送请求时自动将 Cookie 中的 Token 附加在请求头中。

存储到 Cookie 浏览器会自动将存储在 Cookie 中的 Token 随请求发送给服务器,无需前端开发人员手动处理。但是,有如下缺点和挑战:

  1. CSRF(Cross-Site Request Forgery)攻击: 由于 Cookie 会自动随请求发送给目标域名,所以当用户登录并拥有 Cookie 时,恶意网站可以诱使用户在不知情的情况下发送请求,造成 CSRF 攻击。如何防止恶意网站利用用户的 Cookie 发起伪造请求? 使用 SameSite Cookie 属性(设置为 StrictLax),限制 Cookie 在跨站请求中是否发送。Strict 会阻止所有跨站请求发送 Cookie,而 Lax 允许常见的导航请求(如链接点击)发送 Cookie。使用 Anti-CSRF Token:前端应用每次提交表单时,生成并携带一个随机的 CSRF Token,服务器验证该 Token 来确保请求来源的合法性。请求类型限制:使用 POSTPUTDELETE 等修改数据的请求,而不是 GET 请求。GET 请求本身不会修改服务器状态,攻击者难以伪造对服务器的敏感请求。

  2. 跨域问题: Cookie 默认情况下不能跨域传递。为了支持跨域认证,必须设置 SameSite=NoneSecure 属性,这样会让 Cookie 可以跨域,但也增加了潜在的安全风险。如何在安全的前提下实现跨域认证? 配置 CORS(跨域资源共享):通过设置服务端的 Access-Control-Allow-OriginAccess-Control-Allow-Credentials 等头部,来确保跨域时 Cookie 能正确地发送和接收。跨域配置 Cookie:设置 CookieSameSite=NoneSecure 属性,确保它在跨域情况下也能发送。SameSite=None 允许跨域请求时 Cookie 被发送,Secure 确保 Cookie 只有在 HTTPS 环境下传输。

  3. Token 过期和刷新: TokenCookie 中会随着每次请求自动发送,而 Token 通常是有过期时间的。当 Token 过期后,用户需要重新登录。如何确保 Token 的过期机制不会影响用户体验,同时避免过期 Token 的滥用? 使用 Refresh TokenRefresh Token 是一个长期有效的 Token,在 Access Token 过期时,可以在服务端通过 Refresh Token 用来获取新的 Access Token。这样,用户不需要频繁登录。通过滑动过期时间:在每次用户访问时,服务器可以延长 Token 的有效期,确保用户在活跃时不会频繁过期。

二、工作


JWT Token 登录认证并实现 Token 无感刷新 主要的流程可以分为以下几个步骤:

1. 用户登录认证: 前端提交用户名和密码,服务器生成 Access TokenRefresh Token, 将 Access Token 存储在 Cookie 中, 并加上 HttpOnlySecure 保护,防止 JS 获取和防止中间人攻击。将 Refresh Token 存储到 Redis 中, Refresh Token 通常会设置较长的有效期(如 7 天)。

  • Secure:确保 Cookie 只能通过 HTTPS 协议传输,增强安全性。

  • HttpOnly:防止客户端 JavaScript 直接访问 Cookie,从而降低 XSS 攻击的风险。

  • SameSite=None:允许跨域请求时发送 Cookie,解决跨子域的 Cookie 问题。

  • Domain=.example.com:确保 Cookie 对所有子域(如 app.example.comportal.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 TokenRedis 中的 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');
});