feat: 新增 Telegram 登录方式

This commit is contained in:
nap0o committed 2026-02-26 09:55:39 +08:00
1 parent c7ec8c5f5d
commit 0020988f86
15 files changed
+382 -55

No files matched your search

+6
View File
@@ -84,6 +84,9 @@ jobs:
OAUTH_GITEE_CLIENT_ID: ${{ secrets.OAUTH_GITEE_CLIENT_ID }}
OAUTH_GITEE_CLIENT_SECRET: ${{ secrets.OAUTH_GITEE_CLIENT_SECRET }}
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 }}
run: |
SECRETS="OAUTH_ALLOWED_USERS
ENCRYPTION_KEY
@@ -94,6 +97,7 @@ jobs:
"OAUTH_CLOUDFLARE_CLIENT_ID" "OAUTH_CLOUDFLARE_CLIENT_SECRET" "OAUTH_CLOUDFLARE_ORG_DOMAIN" "OAUTH_CLOUDFLARE_REDIRECT_URI"
"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"
)
for VAR in "${OPTIONAL_VARS[@]}"; do
@@ -131,6 +135,8 @@ jobs:
OAUTH_GITEE_CLIENT_ID: ${{ secrets.OAUTH_GITEE_CLIENT_ID }}
OAUTH_GITEE_CLIENT_SECRET: ${{ secrets.OAUTH_GITEE_CLIENT_SECRET }}
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 }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
+8
View File
@@ -28,6 +28,8 @@ export const CSP_POLICY = {
"data:",
"blob:",
"https://avatars.githubusercontent.com",
"https://t.me", // Telegram User Avatars
"https://*.telesco.pe", // Telegram Avatar CDN
"https://www.nodeloc.com",
],
// 连接源: 允许 API 请求、Cloudflare 统计上报
@@ -38,6 +40,10 @@ export const CSP_POLICY = {
"https://cloudflareinsights.com",
"https://static.cloudflareinsights.com",
],
// 框架源
FRAMES: [
"'self'",
],
};
// Cloudflare Workers 环境变量类型定义
@@ -56,6 +62,8 @@ export type EnvBindings = {
OAUTH_GITEE_CLIENT_ID?: string;
OAUTH_GITEE_CLIENT_SECRET?: string;
OAUTH_GITEE_REDIRECT_URI?: string;
OAUTH_TELEGRAM_BOT_NAME?: string; // e.g. MyAuthBot
OAUTH_TELEGRAM_BOT_TOKEN?: string;
OAUTH_ALLOWED_USERS: string; // 允许登录的 Email 或 Username 白名单 (必填)
JWT_SECRET: string;
ENCRYPTION_KEY: string;
+3
View File
@@ -8,6 +8,7 @@ import { EnvBindings, CSP_POLICY } from './config';
import authRoutes from './routes/auth';
import accountsRoutes from './routes/accounts';
import backupRoutes, { handleScheduledBackup } from './routes/backups';
import telegramRoutes from './routes/telegram';
// 扩展 EnvBindings 以包含 ASSETS (Cloudflare Pages/Workers Assets)
type Bindings = EnvBindings & { ASSETS: { fetch: (req: Request) => Promise<Response> } };
@@ -34,6 +35,7 @@ app.use('*', secureHeaders({
imgSrc: CSP_POLICY.IMAGES, // 使用 config.ts 中的配置
connectSrc: CSP_POLICY.CONNECT,// 使用 config.ts 中的配置
fontSrc: ["'self'", "data:"],
frameSrc: CSP_POLICY.FRAMES, // 使用 config.ts 中的配置
workerSrc: ["'self'", "blob:"], // 允许 Service Worker
objectSrc: ["'none'"], // 禁止 Flash 等插件
},
@@ -46,6 +48,7 @@ app.get('/api', (c) => c.text('🔐 2FA Secure Manager API is running!'));
app.route('/api/oauth', authRoutes);
app.route('/api/accounts', accountsRoutes);
app.route('/api/backups', backupRoutes);
app.route('/api/telegram', telegramRoutes); // 挂载 Telegram Webhook 路由
// 4. API 404 处理 (必须在静态资源 fallback 之前,确保 API 路径返回 JSON)
app.all('/api/*', (c) => {
@@ -26,5 +26,6 @@ export abstract class BaseOAuthProvider {
abstract getAuthorizeUrl(state: string): Promise<{ url: string, codeVerifier?: string }> | { url: string, codeVerifier?: string };
// 2. 处理回调:Code -> Token -> UserInfo
abstract handleCallback(code: string, codeVerifier?: string): Promise<OAuthUserInfo>;
// 修改:支持传入 URLSearchParams (Telegram 需要验证所有参数) 或 string (标准 OAuth code)
abstract handleCallback(params: string | URLSearchParams, codeVerifier?: string): Promise<OAuthUserInfo>;
}
@@ -80,7 +80,14 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
return { url, codeVerifier: verifier };
}
async handleCallback(code: string, codeVerifier?: string): Promise<OAuthUserInfo> {
async handleCallback(params: string | URLSearchParams, codeVerifier?: string): Promise<OAuthUserInfo> {
// 兼容处理:如果是 URLSearchParams,提取 code;如果是 string,直接使用
const code = typeof params === 'string' ? params : params.get('code');
if (!code) {
throw new AppError('Cloudflare Access OAuth callback missing code', 400);
}
const clientId = this.env.OAUTH_CLOUDFLARE_CLIENT_ID;
const clientSecret = this.env.OAUTH_CLOUDFLARE_CLIENT_SECRET;
const redirectUri = this.env.OAUTH_CLOUDFLARE_REDIRECT_URI;
@@ -26,7 +26,14 @@ export class GitHubProvider extends BaseOAuthProvider {
return { url: `https://github.com/login/oauth/authorize?${params.toString()}` };
}
async handleCallback(code: string, _codeVerifier?: string): Promise<OAuthUserInfo> {
async handleCallback(params: string | URLSearchParams, _codeVerifier?: string): Promise<OAuthUserInfo> {
// 兼容处理:如果是 URLSearchParams,提取 code;如果是 string,直接使用
const code = typeof params === 'string' ? params : params.get('code');
if (!code) {
throw new AppError('GitHub OAuth callback missing code', 400);
}
// 1. 使用 Code 换取 Access Token
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
+8 -1
View File
@@ -31,7 +31,14 @@ export class GiteeProvider extends BaseOAuthProvider {
return { url: `https://gitee.com/oauth/authorize?${params.toString()}` };
}
async handleCallback(code: string, _codeVerifier?: string): Promise<OAuthUserInfo> {
async handleCallback(params: string | URLSearchParams, _codeVerifier?: string): Promise<OAuthUserInfo> {
// 兼容处理:如果是 URLSearchParams,提取 code;如果是 string,直接使用
const code = typeof params === 'string' ? params : params.get('code');
if (!code) {
throw new AppError('Gitee OAuth callback missing code', 400);
}
const clientId = this.env.OAUTH_GITEE_CLIENT_ID;
const clientSecret = this.env.OAUTH_GITEE_CLIENT_SECRET;
const redirectUri = this.env.OAUTH_GITEE_REDIRECT_URI;
@@ -31,7 +31,14 @@ export class NodeLocProvider extends BaseOAuthProvider {
return { url: `https://www.nodeloc.com/oauth-provider/authorize?${params.toString()}` };
}
async handleCallback(code: string, _codeVerifier?: string): Promise<OAuthUserInfo> {
async handleCallback(params: string | URLSearchParams, _codeVerifier?: string): Promise<OAuthUserInfo> {
// 兼容处理:如果是 URLSearchParams,提取 code;如果是 string,直接使用
const code = typeof params === 'string' ? params : params.get('code');
if (!code) {
throw new AppError('NodeLoc OAuth callback missing code', 400);
}
const clientId = this.env.OAUTH_NODELOC_CLIENT_ID;
const clientSecret = this.env.OAUTH_NODELOC_CLIENT_SECRET;
const redirectUri = this.env.OAUTH_NODELOC_REDIRECT_URI;
@@ -0,0 +1,101 @@
import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider';
import { EnvBindings, AppError } from '../../config';
export class TelegramProvider extends BaseOAuthProvider {
readonly id = 'telegram';
readonly name = 'Telegram';
readonly color = '#54a9eb';
readonly icon = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12a12 12 0 0 0 12-12A12 12 0 0 0 11.944 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472c-.18 1.898-.962 6.502-1.36 8.627c-.168.9-.499 1.201-.82 1.23c-.696.065-1.225-.46-1.9-.902c-1.056-.693-1.653-1.124-2.678-1.8c-1.185-.78-.417-1.21.258-1.91c.177-.184 3.247-2.977 3.307-3.23c.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345c-.48.33-.913.49-1.302.48c-.428-.008-1.252-.241-1.865-.44c-.752-.245-1.349-.374-1.297-.789c.027-.216.325-.437.893-.663c3.498-1.524 5.83-2.529 6.998-3.014c3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>';
readonly whitelistFields = ['username', 'id']; // Telegram 不保证提供 email
constructor(env: EnvBindings) {
super(env);
}
// 修改为 Deep Link 模式:生成跳转到 Bot 的链接,并携带 state 参数
// 例如:https://t.me/MyAuthBot?start=state_value
getAuthorizeUrl(state: string) {
const botName = this.env.OAUTH_TELEGRAM_BOT_NAME;
if (!botName) throw new AppError('Telegram Bot Name not configured', 500);
// 注意:Telegram start 参数只允许 [A-Za-z0-9_-],UUID 通常符合,但最好移除连字符以防万一
// 这里我们直接使用 state,但在前端生成 state 时最好确保它是 URL 安全的
return { url: `https://t.me/${botName}?start=${state}` };
}
async handleCallback(params: string | URLSearchParams, _codeVerifier?: string): Promise<OAuthUserInfo> {
if (typeof params === 'string') {
throw new AppError('Telegram provider requires full query parameters, not just code', 400);
}
const botToken = this.env.OAUTH_TELEGRAM_BOT_TOKEN;
if (!botToken) throw new AppError('Telegram Bot Token not configured', 500);
// 1. 提取并验证必要字段
const hash = params.get('hash');
if (!hash) throw new AppError('Missing hash signature', 400);
const dataCheckArr: string[] = [];
const allowedKeys = ['auth_date', 'first_name', 'id', 'last_name', 'photo_url', 'username'];
// 2. 构造 data-check-string (按字母顺序排序 key=value)
params.forEach((value, key) => {
if (key !== 'hash' && allowedKeys.includes(key)) {
dataCheckArr.push(`${key}=${value}`);
}
});
dataCheckArr.sort();
const dataCheckString = dataCheckArr.join('\n');
// 3. 验证签名 (HMAC-SHA256)
// Secret key 是 Bot Token 的 SHA256 哈希
const encoder = new TextEncoder();
const secretKeyData = await crypto.subtle.digest('SHA-256', encoder.encode(botToken));
const key = await crypto.subtle.importKey(
'raw',
secretKeyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signature = this.hexToBuf(hash);
const isValid = await crypto.subtle.verify(
'HMAC',
key,
signature,
encoder.encode(dataCheckString)
);
if (!isValid) {
throw new AppError('Telegram signature verification failed', 403);
}
// 4. 检查时效性 (Telegram 建议检查 auth_date)
const authDate = parseInt(params.get('auth_date') || '0');
const now = Math.floor(Date.now() / 1000);
if (now - authDate > 86400) { // 24小时过期
throw new AppError('Telegram login data expired', 401);
}
// 5. 返回用户信息
// 注意:Telegram 默认不返回 Email,除非使用 Passport (复杂)
// 这里我们只能依赖 ID 或 Username
return {
id: params.get('id')!,
username: params.get('username') || `tg_user_${params.get('id')}`,
email: '', // Telegram 登录通常没有 Email
avatar: params.get('photo_url') || undefined,
provider: this.id
};
}
private hexToBuf(hex: string): ArrayBuffer {
const view = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
view[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return view.buffer;
}
}
+18 -5
View File
@@ -4,6 +4,7 @@ import { GitHubProvider } from './GitHubProvider';
import { CloudflareAccessProvider } from './CloudflareAccessProvider';
import { NodeLocProvider } from './NodeLocProvider';
import { GiteeProvider } from './GiteeProvider';
import { TelegramProvider } from './TelegramProvider';
export function getOAuthProvider(providerId: string, env: EnvBindings): BaseOAuthProvider {
switch (providerId.toLowerCase()) {
@@ -15,6 +16,8 @@ export function getOAuthProvider(providerId: string, env: EnvBindings): BaseOAut
return new CloudflareAccessProvider(env);
case 'nodeloc':
return new NodeLocProvider(env);
case 'telegram':
return new TelegramProvider(env);
default:
throw new AppError(`Provider '${providerId}' is not supported`, 400);
}
@@ -27,6 +30,7 @@ export function getAvailableProviders(env: EnvBindings) {
const cloudflareProvider = new CloudflareAccessProvider(env);
const nodelocProvider = new NodeLocProvider(env);
const giteeProvider = new GiteeProvider(env);
const telegramProvider = new TelegramProvider(env);
if (env.OAUTH_GITHUB_CLIENT_ID && env.OAUTH_GITHUB_CLIENT_SECRET) {
providers.push({
@@ -37,12 +41,12 @@ export function getAvailableProviders(env: EnvBindings) {
});
}
if (env.OAUTH_GITEE_CLIENT_ID && env.OAUTH_GITEE_CLIENT_SECRET) {
if (env.OAUTH_TELEGRAM_BOT_TOKEN && env.OAUTH_TELEGRAM_BOT_NAME) {
providers.push({
id: giteeProvider.id,
name: giteeProvider.name,
icon: giteeProvider.icon,
color: giteeProvider.color,
id: telegramProvider.id,
name: telegramProvider.name,
icon: telegramProvider.icon,
color: telegramProvider.color,
});
}
@@ -55,6 +59,15 @@ export function getAvailableProviders(env: EnvBindings) {
});
}
if (env.OAUTH_GITEE_CLIENT_ID && env.OAUTH_GITEE_CLIENT_SECRET) {
providers.push({
id: giteeProvider.id,
name: giteeProvider.name,
icon: giteeProvider.icon,
color: giteeProvider.color,
});
}
if (env.OAUTH_NODELOC_CLIENT_ID && env.OAUTH_NODELOC_CLIENT_SECRET) {
providers.push({
id: nodelocProvider.id,
+36 -8
View File
@@ -12,7 +12,16 @@ const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
// ==========================================
auth.get('/providers', (c) => {
const providers = getAvailableProviders(c.env);
return c.json({ success: true, providers });
// 注入 Telegram Bot Name 给前端 Widget 使用
const enhancedProviders = providers.map(p => {
if (p.id === 'telegram') {
// 自动移除可能存在的 @ 前缀,防止 Widget 加载失败
const rawName = c.env.OAUTH_TELEGRAM_BOT_NAME || '';
return { ...p, botName: rawName.replace(/^@/, '') };
}
return p;
});
return c.json({ success: true, providers: enhancedProviders });
});
// ==========================================
@@ -37,15 +46,26 @@ auth.get('/authorize/:provider', async (c) => {
// ==========================================
auth.post('/callback/:provider', async (c) => {
const providerName = c.req.param('provider');
const { code, codeVerifier } = await c.req.json(); // 接收前端传回的 codeVerifier
const body = await c.req.json(); // 获取完整 Body
const env = c.env;
if (!code) {
throw new AppError('Missing OAuth code', 400);
}
const provider = getOAuthProvider(providerName, env);
const userInfo = await provider.handleCallback(code, codeVerifier);
// 构造参数:Telegram 需要验证所有字段,其他 Provider 只需要 code
let params: string | URLSearchParams;
if (providerName === 'telegram') {
params = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.append(key, String(value));
}
});
} else {
if (!body.code) throw new AppError('Missing OAuth code', 400);
params = body.code;
}
const userInfo = await provider.handleCallback(params, body.codeVerifier);
// 严密的安全校验:基于 Email 或 Username 的白名单 (OAUTH_WHITELIST)
const allowedUsersStr = env.OAUTH_ALLOWED_USERS || '';
@@ -69,7 +89,7 @@ auth.post('/callback/:provider', async (c) => {
}
if (! isAllowed) {
throw new AppError(`Unauthorized user. Email: ${userEmail}, Username: ${username}`, 403);
throw new AppError(`Unauthorized user. Email: ${userEmail}, Username: ${userName}`, 403);
}
}
@@ -84,6 +104,10 @@ auth.post('/callback/:provider', async (c) => {
}
};
if (!env.JWT_SECRET) {
throw new AppError('Server configuration error: JWT_SECRET is missing', 500);
}
const token = await generateSecureJWT(payload, env.JWT_SECRET);
// 1. 设置 httpOnly 的鉴权 Cookie (前端无法读取,防 XSS)
@@ -128,6 +152,10 @@ auth.post('/logout', (c) => {
// ==========================================
auth.get('/me', authMiddleware, (c) => {
const user = c.get('user');
// 禁止浏览器缓存此接口,防止登录状态更新不及时
c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
c.header('Pragma', 'no-cache');
c.header('Expires', '0');
return c.json({
success: true,
userInfo: user
+85
View File
@@ -0,0 +1,85 @@
import { Hono } from 'hono';
import { EnvBindings } from '../config';
const telegram = new Hono<{ Bindings: EnvBindings }>();
// 处理 Telegram Webhook
telegram.post('/webhook', async (c) => {
const token = c.env.OAUTH_TELEGRAM_BOT_TOKEN;
if (!token) return c.text('Bot Token not configured', 500);
// 1. 安全校验 (可选,建议在 setWebhook 时设置 secret_token)
// const secretToken = c.req.header('X-Telegram-Bot-Api-Secret-Token');
// if (secretToken !== c.env.TELEGRAM_WEBHOOK_SECRET) return c.text('Unauthorized', 403);
const update = await c.req.json();
// 只处理 Message 类型的 Update
if (!update.message) return c.json({ ok: true });
const chatId = update.message.chat.id;
const text = update.message.text || '';
// 2. 处理 /start 命令
if (text.startsWith('/start')) {
// 提取参数: /start <state>
const args = text.split(' ');
const state = args[1];
if (!state) {
await sendTelegramMessage(token, chatId, '欢迎使用 2FAuth Worker Bot!\n请从网页端发起登录请求。');
return c.json({ ok: true });
}
// 3. 构造 Login URL 按钮
// 这个 URL 必须是你的前端回调地址,Telegram 会在用户点击时验证域名
// 注意:这里我们需要知道前端的域名。通常可以从环境变量或请求头获取,
// 但在 Webhook 中获取请求头可能不准确。建议在 env 中配置 FRONTEND_URL,
// 或者这里暂时硬编码/从 KV 获取。
// 假设我们通过 Referer 或 Origin 无法获取,这里需要你确保配置了 OAUTH_TELEGRAM_BOT_DOMAIN (即你的网站域名)
// 如果没有配置,尝试从 request url 推断 (不一定准)
// ⚠️ 关键:Login URL 必须是 HTTPS 且域名与 setdomain 一致
// 我们构造前端的回调地址,让 Telegram 带着 hash 跳转回来
// 格式: https://your-domain.com/callback/telegram
// 这里的 state 参数其实不需要传给 callback,因为 Telegram 验证通过后,
// 前端拿到的是 hash 数据。state 主要用于防止 CSRF,但在 Login Widget 模式下,
// state 的传递比较特殊。
// 实际上,Login URL 按钮点击后,Telegram 会请求该 URL,并附带 hash 参数。
// 为了简化,我们假设你的 Worker 域名就是你的前端域名
const url = new URL(c.req.url);
const origin = `${url.protocol}//${url.hostname}`;
// 或者使用配置的域名
// const origin = `https://${c.env.OAUTH_TELEGRAM_BOT_DOMAIN}`;
const callbackUrl = `${origin}/callback/telegram`;
await sendTelegramMessage(token, chatId, '请点击下方按钮完成登录验证:', {
inline_keyboard: [[
{
text: '🔐 确认登录',
login_url: { url: callbackUrl, request_write_access: true }
}
]]
});
}
return c.json({ ok: true });
});
async function sendTelegramMessage(token: string, chatId: number, text: string, replyMarkup?: any) {
const url = `https://api.telegram.org/bot${token}/sendMessage`;
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: text,
reply_markup: replyMarkup
})
});
}
export default telegram;
+25 -7
View File
@@ -11,12 +11,18 @@ const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
component: () => import('../views/Login.vue'),
meta: { guestOnly: true } // 仅限游客访问 (已登录会自动跳转首页)
},
{
path: '/oauth/callback', // 匹配 GitHub 回调的路径
path: '/oauth/callback', // 统一的回调路径 (GitHub, Telegram, etc.)
name: 'OAuthCallback',
component: () => import('../views/OAuthCallback.vue')
},
{
path: '/callback/:provider', // 兼容特定 Provider 的回调路径 (如 /callback/telegram)
name: 'ProviderCallback',
component: () => import('../views/OAuthCallback.vue')
}
]
@@ -30,16 +36,28 @@ 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()
// 2. 如果内存无状态,但目标页需要登录 OR 是仅游客页面(如登录页),尝试通过 Cookie 恢复会话
// (Option 1B: 即使访问登录页,也先确认是否已登录,防止误判)
if (!isAuthenticated && (to.meta.requiresAuth || to.meta.guestOnly)) {
try {
await userState.fetchUserInfo()
isAuthenticated = !!(userState.userInfo && userState.userInfo.id)
} catch (e) {}
}
// 3. 处理需要登录的页面
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else {
next()
return
}
// 4. 处理仅限游客的页面 (Option 2B: 已登录用户访问登录页则跳转首页)
if (to.meta.guestOnly && isAuthenticated) {
next('/')
return
}
next()
})
export default router
+20 -17
View File
@@ -8,23 +8,23 @@
</div>
<div class="action-container">
<el-button
v-for="provider in providers"
:key="provider.id"
type="primary"
size="large"
class="oauth-btn"
:style="{ backgroundColor: provider.color, borderColor: provider.color }"
:loading="loadingProvider === provider.id"
:disabled="!!loadingProvider && loadingProvider !== provider.id"
@click="handleLogin(provider.id)"
>
<template #icon>
<el-icon v-if="provider.icon"><span v-html="provider.icon" style="display: flex;"></span></el-icon>
<el-icon v-else><Platform /></el-icon>
</template>
通过 {{ provider.name }} 授权登录
</el-button>
<template v-for="provider in providers" :key="provider.id">
<el-button
type="primary"
size="large"
class="oauth-btn"
:style="{ backgroundColor: provider.color, borderColor: provider.color }"
:loading="loadingProvider === provider.id"
:disabled="!!loadingProvider && loadingProvider !== provider.id"
@click="handleLogin(provider.id)"
>
<template #icon>
<el-icon v-if="provider.icon"><span v-html="provider.icon" style="display: flex;"></span></el-icon>
<el-icon v-else><Platform /></el-icon>
</template>
通过 {{ provider.name }} 授权登录
</el-button>
</template>
</div>
<div class="footer-tips">
@@ -42,9 +42,12 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Lock, Platform } from '@element-plus/icons-vue'
import { userState } from '../states/user'
const router = useRouter()
const loadingProvider = ref(null)
const providers = ref([])
const CACHE_KEY = 'oauth_providers_cache'
+46 -13
View File
@@ -41,7 +41,15 @@ onMounted(async () => {
const code = route.query.code
const state = route.query.state
const error = route.query.error
const providerId = localStorage.getItem('oauth_provider') || 'github'
const hash = route.query.hash // Telegram 特有参数
// 1. 智能识别 Provider
// 优先使用路由参数 (如 /callback/telegram),其次检测 Telegram 特征,最后使用缓存
let providerId = route.params.provider
if (!providerId) {
if (hash) providerId = 'telegram'
else providerId = localStorage.getItem('oauth_provider') || 'github'
}
// 优化:优先从缓存读取提供商名称,避免不必要的网络请求
try {
@@ -63,33 +71,58 @@ onMounted(async () => {
return
}
if (!code || !state) {
errorMsg.value = 'URL 缺少必要的授权凭证参数'
return
// 2. 参数校验 (区分 Telegram 和标准 OAuth)
let payload = {}
if (providerId === 'telegram') {
if (!hash) {
errorMsg.value = 'Telegram 登录缺少签名参数'
return
}
// Telegram 将所有 Query 参数传给后端验签
payload = { ...route.query }
} else {
if (!code || !state) {
errorMsg.value = 'URL 缺少必要的授权凭证参数'
return
}
// 🛡️ 前端 State 校验:防止 CSRF 攻击
const savedState = localStorage.getItem('oauth_state')
if (!savedState || savedState !== state) {
errorMsg.value = '安全警告:State 校验失败,请求可能被篡改'
return
}
const codeVerifier = localStorage.getItem('oauth_code_verifier')
payload = { code, state, codeVerifier }
}
// 🛡️ 前端 State 校验:防止 CSRF 攻击
const savedState = localStorage.getItem('oauth_state')
const codeVerifier = localStorage.getItem('oauth_code_verifier')
if (!savedState || savedState !== state) {
errorMsg.value = '安全警告:State 校验失败,请求可能被篡改'
return
}
localStorage.removeItem('oauth_state') // 校验通过后立即清除,防止重放
localStorage.removeItem('oauth_provider')
localStorage.removeItem('oauth_code_verifier')
try {
// 记录开始时间,确保加载动画至少展示一段时间
const startTime = Date.now()
// 只有 Telegram 才强制等待 1.5s,其他方式保持原速
const MIN_DISPLAY_TIME = providerId === 'telegram' ? 1500 : 0
// 关键步骤:把 code 发给后端换取我们自己的 JWT
const response = await fetch(`/api/oauth/callback/${providerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state, codeVerifier })
body: JSON.stringify(payload)
})
const data = await response.json()
// 计算耗时,如果过快则强制等待,优化用户体验
const elapsed = Date.now() - startTime
if (elapsed < MIN_DISPLAY_TIME) {
await new Promise(resolve => setTimeout(resolve, MIN_DISPLAY_TIME - elapsed))
}
if (data.success) {
// 登录成功!Token 已写入 httpOnly Cookie
// 立即调用接口确认会话有效性,并更新用户信息