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'
+ }]
}
})
],