security: refreshToken 也存储在 HttpOnly Cookie 中

## 后端修改
- 登录时同时设置 token 和 refreshToken 的 HttpOnly Cookie
- refreshToken 有效期7天,token 有效期2小时
- 刷新接口优先从 Cookie 读取 refreshToken(向后兼容请求体)
- 登出时同时清除两个 Cookie

## 前端修改
- 移除 refreshToken 变量和相关逻辑
- 简化 doRefreshToken(),不再手动传递 refreshToken
- 简化 tryRefreshOrLogout(),直接尝试刷新

## 好处
- 页面刷新后 refreshToken 不会丢失
- 完全无感刷新,用户体验更好
- 前端代码更简洁(减少约20行)
- refreshToken 也无法被 XSS 窃取

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-30 10:38:40 +08:00
parent d05e3a22f1
commit 962b01d05a
2 changed files with 30 additions and 32 deletions

View File

@@ -1652,13 +1652,23 @@ app.post('/api/login',
// 增强Cookie安全设置 // 增强Cookie安全设置
const isSecureEnv = process.env.COOKIE_SECURE === 'true'; const isSecureEnv = process.env.COOKIE_SECURE === 'true';
res.cookie('token', token, { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isSecureEnv, secure: isSecureEnv,
// HTTPS环境使用strictHTTP环境使用lax开发环境兼容
sameSite: isSecureEnv ? 'strict' : 'lax', sameSite: isSecureEnv ? 'strict' : 'lax',
maxAge: 2 * 60 * 60 * 1000, // 2小时与access token有效期一致 path: '/'
path: '/' // 限制Cookie作用域 };
// 设置 access token Cookie2小时有效
res.cookie('token', token, {
...cookieOptions,
maxAge: 2 * 60 * 60 * 1000
});
// 设置 refresh token Cookie7天有效
res.cookie('refreshToken', refreshToken, {
...cookieOptions,
maxAge: 7 * 24 * 60 * 60 * 1000
}); });
// 记录登录成功日志 // 记录登录成功日志
@@ -1667,8 +1677,6 @@ app.post('/api/login',
res.json({ res.json({
success: true, success: true,
message: '登录成功', message: '登录成功',
token,
refreshToken, // 返回refresh token
expiresIn: 2 * 60 * 60 * 1000, // 告知前端access token有效期毫秒 expiresIn: 2 * 60 * 60 * 1000, // 告知前端access token有效期毫秒
user: { user: {
id: user.id, id: user.id,
@@ -1694,9 +1702,10 @@ app.post('/api/login',
} }
); );
// 刷新Access Token // 刷新Access Token(从 HttpOnly Cookie 读取 refreshToken
app.post('/api/refresh-token', (req, res) => { app.post('/api/refresh-token', (req, res) => {
const { refreshToken } = req.body; // 优先从 Cookie 读取,兼容从请求体读取(向后兼容)
const refreshToken = req.cookies?.refreshToken || req.body?.refreshToken;
if (!refreshToken) { if (!refreshToken) {
return res.status(400).json({ return res.status(400).json({
@@ -1720,21 +1729,21 @@ app.post('/api/refresh-token', (req, res) => {
httpOnly: true, httpOnly: true,
secure: isSecureEnv, secure: isSecureEnv,
sameSite: isSecureEnv ? 'strict' : 'lax', sameSite: isSecureEnv ? 'strict' : 'lax',
maxAge: 2 * 60 * 60 * 1000, // 2小时 maxAge: 2 * 60 * 60 * 1000,
path: '/' path: '/'
}); });
res.json({ res.json({
success: true, success: true,
token: result.token,
expiresIn: 2 * 60 * 60 * 1000 expiresIn: 2 * 60 * 60 * 1000
}); });
}); });
// 登出清除Cookie // 登出清除Cookie
app.post('/api/logout', (req, res) => { app.post('/api/logout', (req, res) => {
// 清除认证Cookie // 清除所有认证Cookie
res.clearCookie('token', { path: '/' }); res.clearCookie('token', { path: '/' });
res.clearCookie('refreshToken', { path: '/' });
res.json({ success: true, message: '已登出' }); res.json({ success: true, message: '已登出' });
}); });

View File

@@ -10,8 +10,7 @@ createApp({
// 用户状态 // 用户状态
isLoggedIn: false, isLoggedIn: false,
user: null, user: null,
token: null, token: null, // 仅用于内部状态跟踪,实际认证通过 HttpOnly Cookie
refreshToken: null,
tokenRefreshTimer: null, tokenRefreshTimer: null,
// 视图状态 // 视图状态
@@ -527,8 +526,7 @@ handleDragLeave(e) {
const response = await axios.post(`${this.apiBase}/api/login`, this.loginForm); const response = await axios.post(`${this.apiBase}/api/login`, this.loginForm);
if (response.data.success) { if (response.data.success) {
this.token = response.data.token; // token 和 refreshToken 都通过 HttpOnly Cookie 自动管理
this.refreshToken = response.data.refreshToken;
this.user = response.data.user; this.user = response.data.user;
this.isLoggedIn = true; this.isLoggedIn = true;
this.showResendVerify = false; this.showResendVerify = false;
@@ -1002,7 +1000,6 @@ handleDragLeave(e) {
this.isLoggedIn = false; this.isLoggedIn = false;
this.user = null; this.user = null;
this.token = null; this.token = null;
this.refreshToken = null;
this.stopTokenRefresh(); this.stopTokenRefresh();
localStorage.removeItem('user'); localStorage.removeItem('user');
localStorage.removeItem('lastView'); localStorage.removeItem('lastView');
@@ -1086,12 +1083,11 @@ handleDragLeave(e) {
// 尝试刷新token失败则登出 // 尝试刷新token失败则登出
async tryRefreshOrLogout() { async tryRefreshOrLogout() {
if (this.refreshToken) { // refreshToken 通过 Cookie 自动管理,直接尝试刷新
const refreshed = await this.doRefreshToken(); const refreshed = await this.doRefreshToken();
if (refreshed) { if (refreshed) {
await this.checkLoginStatus(); await this.checkLoginStatus();
return; return;
}
} }
this.handleTokenExpired(); this.handleTokenExpired();
}, },
@@ -1102,7 +1098,6 @@ handleDragLeave(e) {
this.isLoggedIn = false; this.isLoggedIn = false;
this.user = null; this.user = null;
this.token = null; this.token = null;
this.refreshToken = null;
this.stopTokenRefresh(); this.stopTokenRefresh();
localStorage.removeItem('user'); localStorage.removeItem('user');
localStorage.removeItem('lastView'); localStorage.removeItem('lastView');
@@ -1130,18 +1125,12 @@ handleDragLeave(e) {
} }
}, },
// 执行token刷新通过 refreshToken 刷新 HttpOnly Cookie 中的 access token // 执行token刷新refreshToken 通过 HttpOnly Cookie 自动发送
async doRefreshToken() { async doRefreshToken() {
if (!this.refreshToken) {
console.log('[认证] 无refresh token无法刷新');
return false;
}
try { try {
console.log('[认证] 正在刷新access token...'); console.log('[认证] 正在刷新access token...');
const response = await axios.post(`${this.apiBase}/api/refresh-token`, { // refreshToken 通过 Cookie 自动携带,无需手动传递
refreshToken: this.refreshToken const response = await axios.post(`${this.apiBase}/api/refresh-token`);
});
if (response.data.success) { if (response.data.success) {
// 后端已自动更新 HttpOnly Cookie 中的 token // 后端已自动更新 HttpOnly Cookie 中的 token