feat: 增加 google 登录方式
This commit is contained in:
1 parent
0020988f86
commit
f112031f4a
13 files changed
+165
-11
No files matched your search
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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('已安全退出')
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}]
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user