diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 95af345..817fc48 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -86,6 +86,9 @@ jobs: OAUTH_GITEE_REDIRECT_URI: ${{ secrets.OAUTH_GITEE_REDIRECT_URI }} OAUTH_TELEGRAM_BOT_NAME: ${{ secrets.OAUTH_TELEGRAM_BOT_NAME }} OAUTH_TELEGRAM_BOT_TOKEN: ${{ secrets.OAUTH_TELEGRAM_BOT_TOKEN }} + OAUTH_GOOGLE_CLIENT_ID: ${{ secrets.OAUTH_GOOGLE_CLIENT_ID }} + OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }} + OAUTH_GOOGLE_REDIRECT_URI: ${{ secrets.OAUTH_GOOGLE_REDIRECT_URI }} run: | SECRETS="OAUTH_ALLOWED_USERS @@ -98,6 +101,7 @@ jobs: "OAUTH_NODELOC_CLIENT_ID" "OAUTH_NODELOC_CLIENT_SECRET" "OAUTH_NODELOC_REDIRECT_URI" "OAUTH_GITEE_CLIENT_ID" "OAUTH_GITEE_CLIENT_SECRET" "OAUTH_GITEE_REDIRECT_URI" "OAUTH_TELEGRAM_BOT_NAME" "OAUTH_TELEGRAM_BOT_TOKEN" + "OAUTH_GOOGLE_CLIENT_ID" "OAUTH_GOOGLE_CLIENT_SECRET" "OAUTH_GOOGLE_REDIRECT_URI" ) for VAR in "${OPTIONAL_VARS[@]}"; do @@ -137,6 +141,9 @@ jobs: OAUTH_GITEE_REDIRECT_URI: ${{ secrets.OAUTH_GITEE_REDIRECT_URI }} OAUTH_TELEGRAM_BOT_NAME: ${{ secrets.OAUTH_TELEGRAM_BOT_NAME }} OAUTH_TELEGRAM_BOT_TOKEN: ${{ secrets.OAUTH_TELEGRAM_BOT_TOKEN }} + OAUTH_GOOGLE_CLIENT_ID: ${{ secrets.OAUTH_GOOGLE_CLIENT_ID }} + OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }} + OAUTH_GOOGLE_REDIRECT_URI: ${{ secrets.OAUTH_GOOGLE_REDIRECT_URI }} ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} JWT_SECRET: ${{ secrets.JWT_SECRET }} diff --git a/backend/src/config.ts b/backend/src/config.ts index 0e2d05c..12e8ae9 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -31,6 +31,7 @@ export const CSP_POLICY = { "https://t.me", // Telegram User Avatars "https://*.telesco.pe", // Telegram Avatar CDN "https://www.nodeloc.com", + "https://lh3.googleusercontent.com", // Google User Avatars ], // 连接源: 允许 API 请求、Cloudflare 统计上报 CONNECT: [ @@ -39,6 +40,8 @@ export const CSP_POLICY = { "https://github.com", "https://cloudflareinsights.com", "https://static.cloudflareinsights.com", + "https://accounts.google.com", + "https://www.googleapis.com", ], // 框架源 FRAMES: [ @@ -62,6 +65,9 @@ export type EnvBindings = { OAUTH_GITEE_CLIENT_ID?: string; OAUTH_GITEE_CLIENT_SECRET?: string; OAUTH_GITEE_REDIRECT_URI?: string; + OAUTH_GOOGLE_CLIENT_ID?: string; + OAUTH_GOOGLE_CLIENT_SECRET?: string; + OAUTH_GOOGLE_REDIRECT_URI?: string; OAUTH_TELEGRAM_BOT_NAME?: string; // e.g. MyAuthBot OAUTH_TELEGRAM_BOT_TOKEN?: string; OAUTH_ALLOWED_USERS: string; // 允许登录的 Email 或 Username 白名单 (必填) diff --git a/backend/src/providers/oauth/CloudflareAccessProvider.ts b/backend/src/providers/oauth/CloudflareAccessProvider.ts index 72ff68c..14c9f13 100644 --- a/backend/src/providers/oauth/CloudflareAccessProvider.ts +++ b/backend/src/providers/oauth/CloudflareAccessProvider.ts @@ -28,8 +28,6 @@ async function generatePKCE() { return { verifier, challenge }; } - - export class CloudflareAccessProvider extends BaseOAuthProvider { readonly id = 'cloudflare'; readonly name = 'Cloudflare Access'; diff --git a/backend/src/providers/oauth/GoogleProvider.ts b/backend/src/providers/oauth/GoogleProvider.ts new file mode 100644 index 0000000..4eaa6c1 --- /dev/null +++ b/backend/src/providers/oauth/GoogleProvider.ts @@ -0,0 +1,93 @@ +import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider'; +import { EnvBindings, AppError } from '../../config'; + +export class GoogleProvider extends BaseOAuthProvider { + readonly id = 'google'; + readonly name = 'Google'; + readonly color = '#33A854'; + // Google "G" Logo SVG + readonly icon = ''; + readonly whitelistFields = ['email']; // Google 主要基于邮箱验证 + + constructor(env: EnvBindings) { + super(env); + } + + getAuthorizeUrl(state: string) { + const clientId = this.env.OAUTH_GOOGLE_CLIENT_ID; + const redirectUri = this.env.OAUTH_GOOGLE_REDIRECT_URI; + + if (!clientId || !redirectUri) { + throw new AppError('Google OAuth configuration incomplete', 500); + } + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state: state, + access_type: 'online', + prompt: 'consent' + }); + + return { url: `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}` }; + } + + async handleCallback(params: string | URLSearchParams, _codeVerifier?: string): Promise { + const code = typeof params === 'string' ? params : params.get('code'); + + if (!code) { + throw new AppError('Google OAuth callback missing code', 400); + } + + const clientId = this.env.OAUTH_GOOGLE_CLIENT_ID; + const clientSecret = this.env.OAUTH_GOOGLE_CLIENT_SECRET; + const redirectUri = this.env.OAUTH_GOOGLE_REDIRECT_URI; + + if (!clientId || !clientSecret || !redirectUri) { + throw new AppError('Google OAuth configuration incomplete', 500); + } + + // 1. Exchange Code for Token + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code: code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }) + }); + + if (!tokenResponse.ok) { + const errText = await tokenResponse.text(); + throw new AppError(`Google Token Exchange failed: ${tokenResponse.status} - ${errText}`, 502); + } + + const tokenData: any = await tokenResponse.json(); + const accessToken = tokenData.access_token; + + // 2. Get User Info + const userResponse = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + if (!userResponse.ok) throw new AppError(`Google API error: ${userResponse.status}`, 502); + const userData: any = await userResponse.json(); + + return { + id: userData.sub, + username: userData.name || userData.email.split('@')[0], + email: userData.email, + avatar: userData.picture, + provider: this.id + }; + } +} \ No newline at end of file diff --git a/backend/src/providers/oauth/index.ts b/backend/src/providers/oauth/index.ts index 4656936..7ceb7f4 100644 --- a/backend/src/providers/oauth/index.ts +++ b/backend/src/providers/oauth/index.ts @@ -5,6 +5,7 @@ import { CloudflareAccessProvider } from './CloudflareAccessProvider'; import { NodeLocProvider } from './NodeLocProvider'; import { GiteeProvider } from './GiteeProvider'; import { TelegramProvider } from './TelegramProvider'; +import { GoogleProvider } from './GoogleProvider'; export function getOAuthProvider(providerId: string, env: EnvBindings): BaseOAuthProvider { switch (providerId.toLowerCase()) { @@ -18,6 +19,8 @@ export function getOAuthProvider(providerId: string, env: EnvBindings): BaseOAut return new NodeLocProvider(env); case 'telegram': return new TelegramProvider(env); + case 'google': + return new GoogleProvider(env); default: throw new AppError(`Provider '${providerId}' is not supported`, 400); } @@ -31,6 +34,16 @@ export function getAvailableProviders(env: EnvBindings) { const nodelocProvider = new NodeLocProvider(env); const giteeProvider = new GiteeProvider(env); const telegramProvider = new TelegramProvider(env); + const googleProvider = new GoogleProvider(env); + + if (env.OAUTH_GOOGLE_CLIENT_ID && env.OAUTH_GOOGLE_CLIENT_SECRET) { + providers.push({ + id: googleProvider.id, + name: googleProvider.name, + icon: googleProvider.icon, + color: googleProvider.color, + }); + } if (env.OAUTH_GITHUB_CLIENT_ID && env.OAUTH_GITHUB_CLIENT_SECRET) { providers.push({ diff --git a/backend/src/routes/accounts.ts b/backend/src/routes/accounts.ts index 636f73c..7affc13 100644 --- a/backend/src/routes/accounts.ts +++ b/backend/src/routes/accounts.ts @@ -74,6 +74,9 @@ accounts.get('/', async (c) => { totalPages: Math.ceil(total / limit) }; + // 关键修复:禁止浏览器缓存账号列表数据,确保用户添加/删除后能立即看到最新状态 + c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + return c.json(response); }); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 9c87d7a..adcfc80 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -12,6 +12,9 @@ const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); // ========================================== auth.get('/providers', (c) => { const providers = getAvailableProviders(c.env); + if (providers.length === 0) { + console.warn('[OAuth] No providers configured. Please check environment variables (e.g. .dev.vars).'); + } // 注入 Telegram Bot Name 给前端 Widget 使用 const enhancedProviders = providers.map(p => { if (p.id === 'telegram') { @@ -139,8 +142,15 @@ auth.post('/callback/:provider', async (c) => { // 2.5 退出登录 (清除 Cookies) // ========================================== auth.post('/logout', (c) => { - deleteCookie(c, 'auth_token'); - deleteCookie(c, 'csrf_token'); + // 关键修复:删除 Cookie 时必须显式指定 path: '/',否则默认是当前路径 (/api/oauth/),导致无法删除根路径下的 Cookie + // 同时建议匹配 secure 和 sameSite 属性以确保精准命中 + const cookieOpts = { path: '/', secure: true, sameSite: 'Strict' as const }; + deleteCookie(c, 'auth_token', cookieOpts); + deleteCookie(c, 'csrf_token', cookieOpts); + + // 增加防缓存头,防止某些浏览器或代理缓存了该请求的响应 + c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + return c.json({ success: true, message: 'Logged out successfully' diff --git a/frontend/src/main.css b/frontend/src/main.css index 54ed09c..e08090d 100644 --- a/frontend/src/main.css +++ b/frontend/src/main.css @@ -205,9 +205,9 @@ main { .batch-text { color: var(--el-text-color-regular); font-size: 14px; } .code-display-area { display: flex; justify-content: space-between; align-items: center; margin-top: auto; padding-left: 24px; padding-top: 15px; } -.code-left { display: flex; flex-direction: column; justify-content: center; } +.code-left { display: flex; flex-direction: row; align-items: baseline; gap: 10px; } .current-code { font-size: 24px; font-weight: bold; color: #409EFF; letter-spacing: 2px; line-height: 1; font-family: monospace; } -.next-code { font-size: 16px; color: var(--el-text-color-secondary); margin-top: 4px; font-family: monospace; } +.next-code { font-size: 16px; color: var(--el-text-color-secondary); font-family: monospace; } .timer-text { font-size: 12px; color: var(--el-text-color-regular); font-weight: bold; display: block; text-align: center; } .el-progress__text { top: 0; left: 0; width: 100%; height: 100%; transform: none; display: flex; align-items: center; justify-content: center; min-width:30px; } /* AccountList - QR Dialog */ diff --git a/frontend/src/states/user.js b/frontend/src/states/user.js index dac52b5..91c8013 100644 --- a/frontend/src/states/user.js +++ b/frontend/src/states/user.js @@ -20,6 +20,8 @@ export const userState = reactive({ clearUserInfo() { this.userInfo = {} localStorage.removeItem('userInfo') + // 关键修复:退出登录时必须清除账号列表缓存,防止敏感数据泄露给下一个用户 + localStorage.removeItem('cached_accounts') }, async logout() { @@ -37,7 +39,8 @@ export const userState = reactive({ async fetchUserInfo() { try { // 使用 silent: true 避免未登录时弹出 "Unauthorized" 错误提示 - const data = await request('/api/oauth/me', { silent: true }) + // 增加时间戳参数 ?_t=... 防止 PWA Service Worker 缓存了 "已登录" 的状态,导致退出后路由守卫误判 + const data = await request(`/api/oauth/me?_t=${Date.now()}`, { silent: true }) if (data.success) { this.setUserInfo(data.userInfo) return true diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js index d02785f..b320e0f 100644 --- a/frontend/src/utils/request.js +++ b/frontend/src/utils/request.js @@ -41,7 +41,11 @@ export async function request(url, options = {}) { ElMessage.error(data.error || '会话已过期或权限不足,请重新登录') localStorage.removeItem('userInfo') - if (router.currentRoute.value.path !== '/login') router.push('/login') + + // 增加判断:确保 router 已就绪且当前不在登录页 + if (router && router.currentRoute.value.path !== '/login') { + router.push('/login').catch(() => {}) // 捕获可能的 NavigationDuplicated 错误 + } throw new Error(data.error || 'Unauthorized/Forbidden') } diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 12bb2d8..97ad186 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -186,8 +186,15 @@ const handleSuccess = () => { } const handleLogout = async () => { + // 1. 移动端优先关闭抽屉,防止遮挡或状态残留 + if (layoutState.showMobileMenu) { + layoutState.showMobileMenu = false + } + await userState.logout() - router.push('/login') + + // 2. 使用 replace 替换当前历史,避免用户点“后退”键又回到已退出的页面 + router.replace('/login') ElMessage.success('已安全退出') } diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 7d0b9aa..f62929d 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -69,7 +69,10 @@ onMounted(async () => { const data = await res.json() if (data.success) { providers.value = data.providers - localStorage.setItem(CACHE_KEY, JSON.stringify(data.providers)) + // 只有当后端返回了有效的登录方式时才更新缓存,防止因配置丢失导致客户端缓存被清空 + if (data.providers && data.providers.length > 0) { + localStorage.setItem(CACHE_KEY, JSON.stringify(data.providers)) + } } } catch (e) { console.error('Failed to load providers', e) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b74ff3b..625815a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -53,7 +53,14 @@ export default defineConfig({ }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], // 缓存所有静态资源 - navigateFallback: '/index.html' // 关键:SPA 单页应用离线必须回退到 index.html + navigateFallback: '/index.html', // 关键:SPA 单页应用离线必须回退到 index.html + // 关键修复:禁止 PWA 拦截 API 请求。防止 API 404/500 时返回 index.html 导致前端 JSON 解析报错 + navigateFallbackDenylist: [/^\/api/], + // 明确 API 走网络优先策略 (NetworkOnly),不进行缓存 + runtimeCaching: [{ + urlPattern: ({ url }) => url.pathname.startsWith('/api'), + handler: 'NetworkOnly' + }] } }) ],