引入 PWA 技术。利用 Service Workers 缓存应用外壳,并将加密后的账号数据缓存在本地。实现“离线优先”策略,仅在有网络时同步数据。
This commit is contained in:
1 parent
10c524bc67
commit
5ac2ae03ce
11 files changed
+185
-30
No files matched your search
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user