引入 PWA 技术。利用 Service Workers 缓存应用外壳,并将加密后的账号数据缓存在本地。实现“离线优先”策略,仅在有网络时同步数据。

This commit is contained in:
nap0o committed 2026-02-24 18:27:27 +08:00
1 parent 10c524bc67
commit 5ac2ae03ce
11 files changed
+185 -30

No files matched your search

+3 -2
View File
@@ -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,
}));
+33 -2
View File
@@ -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;
+13 -4
View File
@@ -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) {
+2 -1
View File
@@ -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"
}
}
+28 -1
View File
@@ -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
}
+13 -1
View File
@@ -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')
+9 -2
View File
@@ -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 {
+14 -1
View File
@@ -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
}
}
})
+26 -12
View File
@@ -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
}
}
+3 -3
View File
@@ -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('/')
+41 -1
View File
@@ -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,