feat: 新增 Telegram 登录方式
This commit is contained in:
1 parent
c7ec8c5f5d
commit
0020988f86
15 files changed
+382
-55
No files matched your search
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
// 立即调用接口确认会话有效性,并更新用户信息
|
||||
|
||||
Reference in new issue
Block a user