security: 实施 HttpOnly Cookie 鉴权方案

## 后端修改
- 新增 /api/logout 接口清除认证 Cookie

## 前端修改
- 移除 localStorage 存储 token/refreshToken(防止 XSS 窃取)
- 移除所有手动 Authorization 头(共36处)
- checkLoginStatus 改为直接调用 API 验证(Cookie 自动携带)
- logout 改为调用后端接口清除 Cookie
- 简化 token 刷新逻辑

## 安全性提升
- Token 从 localStorage 迁移到 HttpOnly Cookie
- XSS 攻击无法通过 JS 读取 token
- 配合 SameSite 属性防御 CSRF 攻击

🤖 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:33:27 +08:00
parent d1c5b0c1bf
commit d05e3a22f1
2 changed files with 82 additions and 153 deletions

View File

@@ -322,9 +322,7 @@ createApp({
// 加载用户主题设置(登录后调用)
async loadUserTheme() {
try {
const res = await axios.get(`${this.apiBase}/api/user/theme`, {
headers: { Authorization: `Bearer ${this.token}` }
});
const res = await axios.get(`${this.apiBase}/api/user/theme`);
if (res.data.success) {
this.globalTheme = res.data.theme.global;
this.userThemePreference = res.data.theme.user;
@@ -352,7 +350,6 @@ createApp({
try {
const res = await axios.post(`${this.apiBase}/api/user/theme`,
{ theme },
{ headers: { Authorization: `Bearer ${this.token}` }}
);
if (res.data.success) {
this.userThemePreference = res.data.theme.user;
@@ -378,7 +375,6 @@ createApp({
console.log('[主题] 设置全局主题:', theme);
const res = await axios.post(`${this.apiBase}/api/admin/settings`,
{ global_theme: theme },
{ headers: { Authorization: `Bearer ${this.token}` }}
);
console.log('[主题] API响应:', res.data);
if (res.data.success) {
@@ -542,9 +538,8 @@ handleDragLeave(e) {
this.showCaptcha = false;
this.loginForm.captcha = '';
// 保存token到localStorage
localStorage.setItem('token', this.token);
localStorage.setItem('refreshToken', this.refreshToken);
// 保存用户信息到localStorage(非敏感信息,用于页面刷新后恢复)
// 注意token 通过 HttpOnly Cookie 传递,不再存储在 localStorage
localStorage.setItem('user', JSON.stringify(this.user));
// 启动token自动刷新在过期前5分钟刷新
@@ -565,11 +560,8 @@ handleDragLeave(e) {
console.log('[登录] SFTP未配置但用户有本地存储权限自动切换到本地存储');
this.storageType = 'local';
// 异步更新到后端(不等待,避免阻塞登录流程)
axios.post(
`${this.apiBase}/api/user/switch-storage`,
{ storage_type: 'local' },
{ headers: { Authorization: `Bearer ${this.token}` } }
).catch(err => console.error('[登录] 自动切换存储类型失败:', err));
axios.post(`${this.apiBase}/api/user/switch-storage`, { storage_type: 'local' })
.catch(err => console.error('[登录] 自动切换存储类型失败:', err));
}
}
@@ -755,7 +747,6 @@ handleDragLeave(e) {
const response = await axios.post(
`${this.apiBase}/api/user/update-ftp`,
this.ftpConfigForm,
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -769,8 +760,7 @@ handleDragLeave(e) {
const switchResponse = await axios.post(
`${this.apiBase}/api/user/switch-storage`,
{ storage_type: 'sftp' },
{ headers: { Authorization: `Bearer ${this.token}` } }
);
);
if (switchResponse.data.success) {
this.storageType = 'sftp';
@@ -800,18 +790,12 @@ handleDragLeave(e) {
{
username: this.adminProfileForm.username
},
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
alert('用户名已更新!请重新登录。');
// 更新token和用户信息
if (response.data.token) {
this.token = response.data.token;
localStorage.setItem('token', response.data.token);
}
// 更新用户信息(后端已通过 Cookie 更新 token
if (response.data.user) {
this.user = response.data.user;
localStorage.setItem('user', JSON.stringify(response.data.user));
@@ -843,7 +827,6 @@ handleDragLeave(e) {
current_password: this.changePasswordForm.current_password,
new_password: this.changePasswordForm.new_password
},
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -860,7 +843,6 @@ handleDragLeave(e) {
try {
const response = await axios.get(
`${this.apiBase}/api/user/profile`,
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success && response.data.user) {
@@ -975,7 +957,6 @@ handleDragLeave(e) {
const response = await axios.post(
`${this.apiBase}/api/user/update-username`,
{ username: this.usernameForm.newUsername },
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -995,7 +976,6 @@ handleDragLeave(e) {
const response = await axios.post(
`${this.apiBase}/api/user/update-profile`,
{ email: this.profileForm.email },
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -1011,14 +991,19 @@ handleDragLeave(e) {
}
},
logout() {
async logout() {
// 调用后端清除 HttpOnly Cookie
try {
await axios.post(`${this.apiBase}/api/logout`);
} catch (err) {
console.error('[登出] 清除Cookie失败:', err);
}
this.isLoggedIn = false;
this.user = null;
this.token = null;
this.refreshToken = null;
this.stopTokenRefresh();
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
localStorage.removeItem('lastView');
this.showResendVerify = false;
@@ -1041,82 +1026,61 @@ handleDragLeave(e) {
}
},
// 检查本地存储的登录状态
// 检查登录状态(通过 HttpOnly Cookie 验证)
async checkLoginStatus() {
const token = localStorage.getItem('token');
const refreshToken = localStorage.getItem('refreshToken');
const user = localStorage.getItem('user');
// 直接调用API验证Cookie会自动携带
try {
const response = await axios.get(`${this.apiBase}/api/user/profile`);
if (token && user) {
this.token = token;
this.refreshToken = refreshToken;
this.user = JSON.parse(user);
if (response.data.success && response.data.user) {
// Cookie有效用户已登录
this.user = response.data.user;
this.isLoggedIn = true;
// 先验证token是否有效
try {
const response = await axios.get(
`${this.apiBase}/api/user/profile`,
{ headers: { Authorization: `Bearer ${token}` } }
);
// 更新localStorage中的用户信息非敏感信息
localStorage.setItem('user', JSON.stringify(this.user));
if (response.data.success && response.data.user) {
// token有效更新用户信息
this.user = response.data.user;
this.isLoggedIn = true;
// 从最新的用户信息初始化存储相关字段
this.storagePermission = this.user.storage_permission || 'sftp_only';
this.storageType = this.user.current_storage_type || 'sftp';
this.localQuota = this.user.local_storage_quota || 0;
this.localUsed = this.user.local_storage_used || 0;
// 更新localStorage中的用户信息
localStorage.setItem('user', JSON.stringify(this.user));
console.log('[页面加载] Cookie验证成功存储权限:', this.storagePermission, '存储类型:', this.storageType);
// 从最新的用户信息初始化存储相关字段
this.storagePermission = this.user.storage_permission || 'sftp_only';
this.storageType = this.user.current_storage_type || 'sftp';
this.localQuota = this.user.local_storage_quota || 0;
this.localUsed = this.user.local_storage_used || 0;
// 启动token自动刷新假设剩余1.5小时,实际由服务端控制)
this.startTokenRefresh(1.5 * 60 * 60 * 1000);
console.log('[页面加载] Token验证成功存储权限:', this.storagePermission, '存储类型:', this.storageType);
// 启动定期检查用户配置
this.startProfileSync();
// 加载用户主题设置
this.loadUserTheme();
// 启动token自动刷新假设剩余1.5小时,实际由服务端控制
this.startTokenRefresh(1.5 * 60 * 60 * 1000);
// 启动定期检查用户配置
this.startProfileSync();
// 加载用户主题设置
this.loadUserTheme();
// 读取上次停留的视图(需合法才生效)
const savedView = localStorage.getItem('lastView');
let targetView = null;
if (savedView && this.isViewAllowed(savedView)) {
targetView = savedView;
} else if (this.user.is_admin) {
targetView = 'admin';
} else if (this.storagePermission === 'sftp_only' && !this.user.has_ftp_config) {
targetView = 'settings';
} else {
targetView = 'files';
}
// 强制切换到目标视图并加载数据
this.switchView(targetView, true);
// 读取上次停留的视图(需合法才生效
const savedView = localStorage.getItem('lastView');
let targetView = null;
if (savedView && this.isViewAllowed(savedView)) {
targetView = savedView;
} else if (this.user.is_admin) {
targetView = 'admin';
} else if (this.storagePermission === 'sftp_only' && !this.user.has_ftp_config) {
targetView = 'settings';
} else {
// 响应异常尝试刷新token
await this.tryRefreshOrLogout();
targetView = 'files';
}
} catch (error) {
console.warn('[页面加载] Token验证失败:', error.response?.status || error.message);
// token无效或过期尝试使用refresh token刷新
if (error.response?.status === 401 && this.refreshToken) {
console.log('[页面加载] 尝试使用refresh token刷新...');
const refreshed = await this.doRefreshToken();
if (refreshed) {
// 刷新成功,重新检查登录状态
await this.checkLoginStatus();
return;
}
}
// 刷新失败或无refresh token清除登录状态
this.handleTokenExpired();
// 强制切换到目标视图并加载数据
this.switchView(targetView, true);
}
} catch (error) {
// 401表示未登录或Cookie过期静默处理用户需要重新登录
if (error.response?.status === 401) {
console.log('[页面加载] 未登录或Cookie已过期');
} else {
console.warn('[页面加载] 验证登录状态失败:', error.message);
}
// 清理可能残留的用户信息
localStorage.removeItem('user');
}
},
@@ -1134,14 +1098,12 @@ handleDragLeave(e) {
// 处理token过期/失效
handleTokenExpired() {
console.log('[认证] Token已失效,清除登录状态');
console.log('[认证] Cookie已失效,清除登录状态');
this.isLoggedIn = false;
this.user = null;
this.token = null;
this.refreshToken = null;
this.stopTokenRefresh();
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
localStorage.removeItem('lastView');
this.stopProfileSync();
@@ -1168,7 +1130,7 @@ handleDragLeave(e) {
}
},
// 执行token刷新
// 执行token刷新(通过 refreshToken 刷新 HttpOnly Cookie 中的 access token
async doRefreshToken() {
if (!this.refreshToken) {
console.log('[认证] 无refresh token无法刷新');
@@ -1182,9 +1144,8 @@ handleDragLeave(e) {
});
if (response.data.success) {
this.token = response.data.token;
localStorage.setItem('token', this.token);
console.log('[认证] Token刷新成功');
// 后端已自动更新 HttpOnly Cookie 中的 token
console.log('[认证] Token刷新成功Cookie已更新');
// 继续下一次刷新
const expiresIn = response.data.expiresIn || (2 * 60 * 60 * 1000);
@@ -1220,8 +1181,7 @@ handleDragLeave(e) {
try {
const response = await axios.get(`${this.apiBase}/api/files`, {
params: { path },
headers: { Authorization: `Bearer ${this.token}` }
params: { path }
});
if (response.data.success) {
@@ -1325,7 +1285,6 @@ handleDragLeave(e) {
const response = await axios.post(
`${this.apiBase}/api/files/rename`,
this.renameForm,
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -1358,8 +1317,6 @@ handleDragLeave(e) {
const response = await axios.post(`${this.apiBase}/api/files/mkdir`, {
path: this.currentPath,
folderName: folderName
}, {
headers: { 'Authorization': `Bearer ${this.token}` }
});
if (response.data.success) {
@@ -1388,8 +1345,6 @@ handleDragLeave(e) {
const response = await axios.post(`${this.apiBase}/api/files/folder-info`, {
path: this.currentPath,
folderName: file.name
}, {
headers: { 'Authorization': `Bearer ${this.token}` }
});
if (response.data.success) {
@@ -1614,7 +1569,6 @@ handleDragLeave(e) {
path: this.currentPath,
isDirectory: file.isDirectory
},
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -1680,7 +1634,6 @@ handleDragLeave(e) {
password: this.shareAllForm.password || null,
expiry_days: expiryDays
},
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -1712,7 +1665,6 @@ handleDragLeave(e) {
password: this.shareFileForm.password || null,
expiry_days: expiryDays
},
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -1798,7 +1750,6 @@ handleDragLeave(e) {
const response = await axios.post(`${this.apiBase}/api/upload`, formData, {
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'multipart/form-data'
},
timeout: 30 * 60 * 1000, // 30分钟超时支持大文件上传
@@ -1864,9 +1815,7 @@ handleDragLeave(e) {
async loadShares() {
try {
const response = await axios.get(`${this.apiBase}/api/share/my`, {
headers: { Authorization: `Bearer ${this.token}` }
});
const response = await axios.get(`${this.apiBase}/api/share/my`);
if (response.data.success) {
this.shares = response.data.shares;
@@ -1881,9 +1830,7 @@ handleDragLeave(e) {
this.shareForm.path = this.currentPath;
try {
const response = await axios.post(`${this.apiBase}/api/share/create`, this.shareForm, {
headers: { Authorization: `Bearer ${this.token}` }
});
const response = await axios.post(`${this.apiBase}/api/share/create`, this.shareForm);
if (response.data.success) {
this.shareResult = response.data;
@@ -1899,9 +1846,7 @@ handleDragLeave(e) {
if (!confirm('确定要删除这个分享吗?')) return;
try {
const response = await axios.delete(`${this.apiBase}/api/share/${id}`, {
headers: { Authorization: `Bearer ${this.token}` }
});
const response = await axios.delete(`${this.apiBase}/api/share/${id}`);
if (response.data.success) {
alert('分享已删除');
@@ -2000,9 +1945,7 @@ handleDragLeave(e) {
async loadUsers() {
try {
const response = await axios.get(`${this.apiBase}/api/admin/users`, {
headers: { Authorization: `Bearer ${this.token}` }
});
const response = await axios.get(`${this.apiBase}/api/admin/users`);
if (response.data.success) {
this.adminUsers = response.data.users;
@@ -2021,7 +1964,6 @@ handleDragLeave(e) {
const response = await axios.post(
`${this.apiBase}/api/admin/users/${userId}/ban`,
{ banned },
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -2038,9 +1980,7 @@ handleDragLeave(e) {
if (!confirm('确定要删除这个用户吗?此操作不可恢复!')) return;
try {
const response = await axios.delete(`${this.apiBase}/api/admin/users/${userId}`, {
headers: { Authorization: `Bearer ${this.token}` }
});
const response = await axios.delete(`${this.apiBase}/api/admin/users/${userId}`);
if (response.data.success) {
alert('用户已删除');
@@ -2123,8 +2063,7 @@ handleDragLeave(e) {
const response = await axios.get(
`${this.apiBase}/api/admin/users/${this.inspectionUser.id}/files`,
{
params: { path },
headers: { Authorization: `Bearer ${this.token}` }
params: { path }
}
);
@@ -2166,7 +2105,6 @@ handleDragLeave(e) {
try {
const response = await axios.get(
`${this.apiBase}/api/user/profile`,
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success && response.data.user) {
@@ -2228,7 +2166,6 @@ handleDragLeave(e) {
try {
const response = await axios.get(
`${this.apiBase}/api/user/sftp-usage`,
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -2287,7 +2224,6 @@ handleDragLeave(e) {
const response = await axios.post(
`${this.apiBase}/api/user/switch-storage`,
{ storage_type: type },
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -2426,7 +2362,6 @@ handleDragLeave(e) {
storage_permission: this.editStorageForm.storage_permission,
local_storage_quota: quotaBytes
},
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -2506,9 +2441,7 @@ handleDragLeave(e) {
async loadSystemSettings() {
try {
const response = await axios.get(`${this.apiBase}/api/admin/settings`, {
headers: { Authorization: `Bearer ${this.token}` }
});
const response = await axios.get(`${this.apiBase}/api/admin/settings`);
if (response.data.success) {
const settings = response.data.settings;
@@ -2537,9 +2470,7 @@ handleDragLeave(e) {
async loadServerStorageStats() {
try {
const response = await axios.get(`${this.apiBase}/api/admin/storage-stats`, {
headers: { Authorization: `Bearer ${this.token}` }
});
const response = await axios.get(`${this.apiBase}/api/admin/storage-stats`);
if (response.data.success) {
this.serverStorageStats = response.data.stats;
@@ -2570,8 +2501,7 @@ handleDragLeave(e) {
const response = await axios.post(
`${this.apiBase}/api/admin/settings`,
payload,
{ headers: { Authorization: `Bearer ${this.token}` } }
payload
);
if (response.data.success) {
@@ -2590,7 +2520,6 @@ handleDragLeave(e) {
const response = await axios.post(
`${this.apiBase}/api/admin/settings/test-smtp`,
{ to: this.systemSettings.smtp.user },
{ headers: { Authorization: `Bearer ${this.token}` } }
);
this.showToast('success', '成功', response.data.message || '测试邮件已发送');
} catch (error) {
@@ -2604,9 +2533,7 @@ handleDragLeave(e) {
async loadHealthCheck() {
this.healthCheck.loading = true;
try {
const response = await axios.get(`${this.apiBase}/api/admin/health-check`, {
headers: { Authorization: `Bearer ${this.token}` }
});
const response = await axios.get(`${this.apiBase}/api/admin/health-check`);
if (response.data.success) {
this.healthCheck.overallStatus = response.data.overallStatus;
@@ -2680,9 +2607,7 @@ handleDragLeave(e) {
params.append('keyword', this.systemLogs.filters.keyword);
}
const response = await axios.get(`${this.apiBase}/api/admin/logs?${params}`, {
headers: { Authorization: `Bearer ${this.token}` }
});
const response = await axios.get(`${this.apiBase}/api/admin/logs?${params}`);
if (response.data.success) {
this.systemLogs.logs = response.data.logs;
@@ -2766,7 +2691,6 @@ handleDragLeave(e) {
const response = await axios.post(
`${this.apiBase}/api/admin/logs/cleanup`,
{ keepDays: 90 },
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -2787,7 +2711,6 @@ handleDragLeave(e) {
try {
const response = await axios.get(
`${this.apiBase}/api/admin/check-upload-tool`,
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
@@ -2844,7 +2767,6 @@ handleDragLeave(e) {
formData,
{
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'multipart/form-data'
}
}