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:
@@ -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环境使用strict,HTTP环境使用lax(开发环境兼容)
|
|
||||||
sameSite: isSecureEnv ? 'strict' : 'lax',
|
sameSite: isSecureEnv ? 'strict' : 'lax',
|
||||||
maxAge: 2 * 60 * 60 * 1000, // 2小时(与access token有效期一致)
|
path: '/'
|
||||||
path: '/' // 限制Cookie作用域
|
};
|
||||||
|
|
||||||
|
// 设置 access token Cookie(2小时有效)
|
||||||
|
res.cookie('token', token, {
|
||||||
|
...cookieOptions,
|
||||||
|
maxAge: 2 * 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置 refresh token Cookie(7天有效)
|
||||||
|
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: '已登出' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user