feat: 增加 google 登录方式

This commit is contained in:
nap0o committed 2026-02-27 00:54:40 +08:00
1 parent 0020988f86
commit f112031f4a
13 files changed
+165 -11

No files matched your search

+7
View File
@@ -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 }}
+6
View File
@@ -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 白名单 (必填)
@@ -28,8 +28,6 @@ async function generatePKCE() {
return { verifier, challenge };
}
export class CloudflareAccessProvider extends BaseOAuthProvider {
readonly id = 'cloudflare';
readonly name = 'Cloudflare Access';
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M21.35 11.1h-9.17v2.73h6.51c-.33 3.81-3.5 5.44-6.5 5.44C8.36 19.27 5 16.25 5 12c0-4.1 3.2-7.27 7.2-7.27c3.09 0 4.9 1.97 4.9 1.97L19 4.72S16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12c0 5.05 4.13 10 10.22 10c5.35 0 9.25-3.67 9.25-9.09c0-1.15-.15-1.81-.15-1.81Z"/></svg>';
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<OAuthUserInfo> {
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
};
}
}
+13
View File
@@ -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({
+3
View File
@@ -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);
});
+12 -2
View File
@@ -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'
+2 -2
View File
@@ -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 */
+4 -1
View File
@@ -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
+5 -1
View File
@@ -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')
}
+8 -1
View File
@@ -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('已安全退出')
}
+4 -1
View File
@@ -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)
+8 -1
View File
@@ -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'
}]
}
})
],