liuzhao521
Add OAuth login support for Hugging Face Spaces
15bfd5e
import { Router } from 'express';
import https from 'https';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import logger from '../utils/logger.js';
const router = Router();
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const CLIENT_SECRET = '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';
const ACCOUNTS_FILE = path.join(process.cwd(), 'data', 'accounts.json');
const SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs'
];
// 存储 state 用于验证
const pendingStates = new Map();
// 获取回调基础 URL
function getBaseUrl(req) {
// 优先使用环境变量
if (process.env.OAUTH_CALLBACK_URL) {
return process.env.OAUTH_CALLBACK_URL;
}
// 从请求中推断
const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const host = req.headers['x-forwarded-host'] || req.headers.host;
return `${protocol}://${host}`;
}
// 生成授权 URL
router.get('/login', (req, res) => {
const state = crypto.randomUUID();
const baseUrl = getBaseUrl(req);
const redirectUri = `${baseUrl}/oauth-callback`;
// 保存 state,5分钟过期
pendingStates.set(state, { redirectUri, timestamp: Date.now() });
setTimeout(() => pendingStates.delete(state), 5 * 60 * 1000);
const params = new URLSearchParams({
access_type: 'offline',
client_id: CLIENT_ID,
prompt: 'consent',
redirect_uri: redirectUri,
response_type: 'code',
scope: SCOPES.join(' '),
state: state
});
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
// 返回 HTML 页面,用户点击后跳转
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Google 账号登录</title>
<style>
body { font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
.container { text-align: center; background: white; padding: 40px; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; margin-bottom: 20px; }
p { color: #666; margin-bottom: 30px; }
a { display: inline-block; background: #4285f4; color: white; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 500; }
a:hover { background: #3367d6; }
.info { font-size: 12px; color: #999; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 添加 Google 账号</h1>
<p>点击下方按钮使用 Google 账号授权</p>
<a href="${authUrl}">使用 Google 登录</a>
<p class="info">回调地址: ${redirectUri}</p>
</div>
</body>
</html>
`);
});
// OAuth 回调处理
router.get('/oauth-callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) {
logger.error('OAuth 授权失败:', error);
return res.send('<h1>授权失败</h1><p>' + error + '</p>');
}
if (!code) {
return res.send('<h1>授权失败</h1><p>未收到授权码</p>');
}
// 验证 state
const stateData = pendingStates.get(state);
if (!stateData) {
logger.warn('无效的 state 参数,可能是过期或重复使用');
// 尝试从当前请求推断 redirect_uri
}
const redirectUri = stateData?.redirectUri || `${getBaseUrl(req)}/oauth-callback`;
pendingStates.delete(state);
try {
logger.info('收到授权码,正在交换 Token...');
const tokenData = await exchangeCodeForToken(code, redirectUri);
const account = {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token,
expires_in: tokenData.expires_in,
timestamp: Date.now()
};
// 保存到 accounts.json
let accounts = [];
try {
if (fs.existsSync(ACCOUNTS_FILE)) {
accounts = JSON.parse(fs.readFileSync(ACCOUNTS_FILE, 'utf-8'));
}
} catch (err) {
logger.warn('读取 accounts.json 失败,将创建新文件');
}
accounts.push(account);
const dir = path.dirname(ACCOUNTS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(accounts, null, 2));
logger.info(`Token 已保存,当前共 ${accounts.length} 个账号`);
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权成功</title>
<style>
body { font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
.container { text-align: center; background: white; padding: 40px; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #4caf50; }
p { color: #666; }
</style>
</head>
<body>
<div class="container">
<h1>✅ 授权成功!</h1>
<p>账号已添加,当前共 ${accounts.length} 个账号</p>
<p><a href="/">返回首页</a></p>
</div>
</body>
</html>
`);
} catch (err) {
logger.error('Token 交换失败:', err.message);
res.send(`<h1>Token 获取失败</h1><p>${err.message}</p>`);
}
});
// 交换授权码获取 Token
function exchangeCodeForToken(code, redirectUri) {
return new Promise((resolve, reject) => {
const postData = new URLSearchParams({
code: code,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
}).toString();
const options = {
hostname: 'oauth2.googleapis.com',
path: '/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
resolve(JSON.parse(body));
} else {
reject(new Error(`HTTP ${res.statusCode}: ${body}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
export default router;