From 5ac2ae03ce523bb852504deecb6ec46a4759def7 Mon Sep 17 00:00:00 2001 From: nap0o Date: Tue, 24 Feb 2026 18:27:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=95=E5=85=A5=20PWA=20=E6=8A=80=E6=9C=AF?= =?UTF-8?q?=E3=80=82=E5=88=A9=E7=94=A8=20Service=20Workers=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=BA=94=E7=94=A8=E5=A4=96=E5=A3=B3=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=B0=86=E5=8A=A0=E5=AF=86=E5=90=8E=E7=9A=84=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=BC=93=E5=AD=98=E5=9C=A8=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E3=80=82=E5=AE=9E=E7=8E=B0=E2=80=9C=E7=A6=BB=E7=BA=BF=E4=BC=98?= =?UTF-8?q?=E5=85=88=E2=80=9D=E7=AD=96=E7=95=A5=EF=BC=8C=E4=BB=85=E5=9C=A8?= =?UTF-8?q?=E6=9C=89=E7=BD=91=E7=BB=9C=E6=97=B6=E5=90=8C=E6=AD=A5=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/index.ts | 5 +-- backend/src/routes/auth.ts | 35 +++++++++++++++++++-- backend/src/utils/helper.ts | 17 +++++++--- frontend/package.json | 3 +- frontend/src/components/AccountList.vue | 29 ++++++++++++++++- frontend/src/main.js | 14 ++++++++- frontend/src/router/index.js | 11 +++++-- frontend/src/states/user.js | 15 ++++++++- frontend/src/utils/request.js | 38 +++++++++++++++------- frontend/src/views/OAuthCallback.vue | 6 ++-- frontend/vite.config.js | 42 ++++++++++++++++++++++++- 11 files changed, 185 insertions(+), 30 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index c9a2c51..d9b96b9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,8 +17,9 @@ const app = new Hono<{ Bindings: Bindings }>(); // 1. 全局中间件 app.use('*', logger()); // 自动打印请求日志 app.use('/api/*', cors({ - origin: '*', // 开发环境允许跨域,生产环境可配置具体域名 - allowHeaders: ['Content-Type', 'Authorization'], + origin: (origin) => origin, // 允许携带 Cookie 时,Origin 不能为 *,这里改为动态反射 + credentials: true, // 允许浏览器发送 Cookie + allowHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'], // 允许自定义 CSRF 头 allowMethods: ['POST', 'GET', 'OPTIONS', 'PUT', 'DELETE'], maxAge: 86400, })); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 1764931..4950b1e 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,8 +1,10 @@ import { Hono } from 'hono'; +import { setCookie } from 'hono/cookie'; import { EnvBindings, AppError } from '../config'; import { generateSecureJWT } from '../utils/crypto'; +import { authMiddleware } from '../utils/helper'; -const auth = new Hono<{ Bindings: EnvBindings }>(); +const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); // 内部辅助函数:获取 GitHub 用户信息 async function fetchGitHubUser(accessToken: string, oauthBaseUrl: string) { @@ -103,11 +105,40 @@ auth.post('/callback', async (c) => { const token = await generateSecureJWT(payload, env.JWT_SECRET); + // 1. 设置 httpOnly 的鉴权 Cookie (前端无法读取,防 XSS) + setCookie(c, 'auth_token', token, { + httpOnly: true, + secure: true, + sameSite: 'Strict', + maxAge: 7 * 24 * 60 * 60, // 7天 + path: '/', + }); + + // 2. 设置 CSRF Token Cookie (前端可以读取,用于请求头) + const csrfToken = crypto.randomUUID(); + setCookie(c, 'csrf_token', csrfToken, { + httpOnly: false, // 允许前端 JS 读取 + secure: true, + sameSite: 'Strict', + maxAge: 7 * 24 * 60 * 60, + path: '/', + }); + return c.json({ success: true, - token, userInfo: payload.userInfo }); }); +// ========================================== +// 3. 获取当前用户信息 (前端 Token 变为 httpOnly 后需要此接口) +// ========================================== +auth.get('/me', authMiddleware, (c) => { + const user = c.get('user'); + return c.json({ + success: true, + userInfo: user + }); +}); + export default auth; \ No newline at end of file diff --git a/backend/src/utils/helper.ts b/backend/src/utils/helper.ts index 3a6722c..877eab5 100644 --- a/backend/src/utils/helper.ts +++ b/backend/src/utils/helper.ts @@ -1,4 +1,5 @@ import { Context, Next } from 'hono'; +import { getCookie } from 'hono/cookie'; import { SECURITY_CONFIG, AppError, EnvBindings } from '../config'; import { verifySecureJWT } from './crypto'; @@ -63,12 +64,20 @@ export function parseOTPAuthURI(uri: string) { // 3. Hono JWT 鉴权中间件 export async function authMiddleware(c: Context<{ Bindings: EnvBindings, Variables: { user: any } }>, next: Next) { - const authHeader = c.req.header('Authorization'); - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new AppError('Unauthorized: Missing or invalid token', 401); + // 1. 从 Cookie 获取 JWT + const token = getCookie(c, 'auth_token'); + if (!token) { + throw new AppError('Unauthorized: Missing session cookie', 401); } - const token = authHeader.substring(7); + // 2. CSRF 校验 (Double Submit Cookie) + const csrfCookie = getCookie(c, 'csrf_token'); + const csrfHeader = c.req.header('X-CSRF-Token'); + if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) { + throw new AppError('Forbidden: CSRF token mismatch', 403); + } + + // 3. 验证 JWT const payload = await verifySecureJWT(token, c.env.JWT_SECRET); if (!payload || !payload.userInfo) { diff --git a/frontend/package.json b/frontend/package.json index 5471f62..8fedfba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@playwright/test": "^1.58.2", "@types/node": "^25.3.0", "@vitejs/plugin-vue": "^6.0.2", - "vite": "^7.3.1" + "vite": "^6.0.0", + "vite-plugin-pwa": "^0.21.1" } } diff --git a/frontend/src/components/AccountList.vue b/frontend/src/components/AccountList.vue index af2285c..8b18cd5 100644 --- a/frontend/src/components/AccountList.vue +++ b/frontend/src/components/AccountList.vue @@ -170,6 +170,7 @@ const total = ref(0) const searchQuery = ref('') const currentPage = ref(1) const pageSize = ref(12) +const isOfflineMode = ref(false) // --- 批量操作 --- const selectedIds = ref([]) @@ -240,7 +241,20 @@ let searchTimer = null // --- 核心逻辑 --- // 获取账号列表 const fetchAccounts = async () => { - loading.value = true + // 1. 离线优先策略:先尝试加载本地缓存 + const cachedData = localStorage.getItem('cached_accounts') + if (cachedData) { + try { + const parsed = JSON.parse(cachedData) + accounts.value = parsed.accounts || [] + if (parsed.pagination) total.value = parsed.pagination.total + updateAccountsStatus() // 立即开始倒计时 + loading.value = false // 有缓存就不显示全屏 Loading,体验更好 + } catch (e) { console.error('Cache load failed', e) } + } else { + loading.value = true + } + try { const query = new URLSearchParams({ page: currentPage.value, @@ -250,12 +264,25 @@ const fetchAccounts = async () => { const data = await request(`/api/accounts?${query}`) if (data.success) { + // 2. 网络请求成功:更新缓存 (仅在第一页且无搜索时缓存,避免缓存了局部数据) + if (currentPage.value === 1 && !searchQuery.value) { + localStorage.setItem('cached_accounts', JSON.stringify(data)) + } + isOfflineMode.value = false accounts.value = data.accounts || [] updateAccountsStatus() // 立即计算一次 if (data.pagination) total.value = data.pagination.total } } catch (error) { console.error('Failed to fetch accounts', error) + // 3. 网络请求失败:如果是离线状态且已有缓存,则不报错 + if (!navigator.onLine || error.message.includes('Failed to fetch')) { + isOfflineMode.value = true + if (accounts.value.length > 0) { + ElMessage.warning('网络不可用,正在使用离线数据') + return + } + } } finally { loading.value = false } diff --git a/frontend/src/main.js b/frontend/src/main.js index ebe58b4..a459691 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,5 +1,6 @@ import { createApp } from 'vue' -import ElementPlus from 'element-plus' +import ElementPlus, { ElMessage } from 'element-plus' +import { registerSW } from 'virtual:pwa-register' import 'element-plus/dist/index.css' import 'element-plus/theme-chalk/dark/css-vars.css' import './main.css' @@ -13,6 +14,17 @@ const app = createApp(App) // 初始化主题状态 (从 localStorage 恢复) initTheme() +// PWA Service Worker 注册 +registerSW({ + onOfflineReady() { + // 当 App 所有的资源都被缓存完毕,可以离线访问时触发 + ElMessage.success({ + message: '应用已缓存,支持离线访问', + duration: 3000 + }) + }, +}) + app.use(router) app.use(ElementPlus) app.mount('#app') \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index c6b4b31..5d4fe0a 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' +import { userState } from '../states/user' const routes = [ { @@ -25,9 +26,15 @@ const router = createRouter({ }) // 路由守卫:拦截未登录用户的访问 -router.beforeEach((to, from, next) => { - const isAuthenticated = !!localStorage.getItem('authToken') +router.beforeEach(async (to, from, next) => { + // 1. 检查内存中是否有用户信息 (乐观检查) + let isAuthenticated = !!(userState.userInfo && userState.userInfo.id) + // 2. 如果内存无状态且目标页需要登录,尝试通过 Cookie 恢复会话 (例如刷新页面后) + if (!isAuthenticated && to.meta.requiresAuth) { + isAuthenticated = await userState.fetchUserInfo() + } + if (to.meta.requiresAuth && !isAuthenticated) { next('/login') } else { diff --git a/frontend/src/states/user.js b/frontend/src/states/user.js index cbded1c..73a4548 100644 --- a/frontend/src/states/user.js +++ b/frontend/src/states/user.js @@ -1,4 +1,5 @@ import { reactive } from 'vue' +import { request } from '../utils/request' const getStoredUser = () => { try { @@ -19,6 +20,18 @@ export const userState = reactive({ clearUserInfo() { this.userInfo = {} localStorage.removeItem('userInfo') - localStorage.removeItem('authToken') + }, + + async fetchUserInfo() { + try { + const data = await request('/api/oauth/me') + if (data.success) { + this.setUserInfo(data.userInfo) + return true + } + } catch (e) { + this.clearUserInfo() + return false + } } }) \ No newline at end of file diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js index a763cec..5099b8e 100644 --- a/frontend/src/utils/request.js +++ b/frontend/src/utils/request.js @@ -1,29 +1,43 @@ import { ElMessage } from 'element-plus' import router from '../router' +// 辅助函数:从 document.cookie 中安全地读取指定的 cookie 值 +function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); +} + export async function request(url, options = {}) { - // 自动携带 Token - const token = localStorage.getItem('authToken') + // 1. 移除旧的 localStorage Token 逻辑 const headers = { 'Content-Type': 'application/json', ...options.headers, } - if (token) { - headers['Authorization'] = `Bearer ${token}` + // 2. 新增:读取 CSRF Token 并添加到请求头 + const csrfToken = getCookie('csrf_token'); + if (csrfToken) { + headers['X-CSRF-Token'] = csrfToken; } try { - const response = await fetch(url, { ...options, headers }) + // 3. 新增:配置 credentials: 'include' 以自动发送 cookie + const response = await fetch(url, { ...options, headers, credentials: 'include' }) + + // 针对 204 No Content 等情况,没有 body,直接返回成功 + if (response.status === 204) { + return { success: true }; + } + const data = await response.json() - // 处理 401 未登录或 Token 过期 - if (response.status === 401) { - ElMessage.error('登录已过期,请重新登录') - localStorage.removeItem('authToken') + // 4. 更新:处理 401/403 未登录或 CSRF 失败 + if (response.status === 401 || response.status === 403) { + ElMessage.error(data.error || '会话已过期或权限不足,请重新登录') localStorage.removeItem('userInfo') - router.push('/login') - throw new Error('Unauthorized') + if (router.currentRoute.value.path !== '/login') router.push('/login') + throw new Error(data.error || 'Unauthorized/Forbidden') } // 处理其他报错 @@ -34,7 +48,7 @@ export async function request(url, options = {}) { return data } catch (error) { - console.error('API Request Error:', error) + if (error.message !== 'Unauthorized/Forbidden') console.error('API Request Error:', error) throw error } } \ No newline at end of file diff --git a/frontend/src/views/OAuthCallback.vue b/frontend/src/views/OAuthCallback.vue index 8f53d36..21c5fad 100644 --- a/frontend/src/views/OAuthCallback.vue +++ b/frontend/src/views/OAuthCallback.vue @@ -70,9 +70,9 @@ onMounted(async () => { const data = await response.json() if (data.success) { - // 登录成功!把后端发的系统门票存进浏览器 - localStorage.setItem('authToken', data.token) - userState.setUserInfo(data.userInfo) + // 登录成功!Token 已写入 httpOnly Cookie + // 立即调用接口确认会话有效性,并更新用户信息 + await userState.fetchUserInfo() // 一把推开大门,进入主界面 router.push('/') diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c32fc1d..aeb43e1 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,8 +1,48 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import { VitePWA } from 'vite-plugin-pwa' export default defineConfig({ - plugins: [vue()], + plugins: [ + vue(), + VitePWA({ + registerType: 'autoUpdate', // 自动更新 Service Worker + includeAssets: ['favicon.ico', 'logo.svg', 'apple-touch-icon.png'], // 静态资源缓存 + devOptions: { + enabled: true // 开启开发环境支持 + }, + manifest: { + name: '2FAuth - Secure 2FA Manager', + short_name: '2FAuth', + description: 'A secure, serverless 2FA management tool.', + theme_color: '#ffffff', + background_color: '#ffffff', + display: 'standalone', // 独立应用模式 (无浏览器地址栏) + icons: [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png' + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png' + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable' // 适应不同形状的图标 (圆角/圆形) + } + ] + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], // 缓存所有静态资源 + navigateFallback: '/index.html' // 关键:SPA 单页应用离线必须回退到 index.html + } + }) + ], server: { host: '0.0.0.0', // 兼容 Docker DevContainer port: 5173,