286 lines
7.1 KiB
JavaScript
286 lines
7.1 KiB
JavaScript
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const crypto = require('crypto');
|
|||
|
|
const axios = require('axios');
|
|||
|
|
const { URL } = require('url');
|
|||
|
|
|
|||
|
|
// ─── 常量 ───
|
|||
|
|
const SESSION_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000; // 7天
|
|||
|
|
const TOKEN_BYTES = 32;
|
|||
|
|
const OWNER_EMAIL = (process.env.ZY_OWNER_EMAIL || '<<@committer_handle:base64>>').trim().toLowerCase();
|
|||
|
|
|
|||
|
|
// ─── GitHub OAuth 配置 ───
|
|||
|
|
const GITHUB_CLIENT_ID = process.env.ZY_GITHUB_CLIENT_ID;
|
|||
|
|
const GITHUB_CLIENT_SECRET = process.env.ZY_GITHUB_CLIENT_SECRET;
|
|||
|
|
const GITHUB_REDIRECT_URI = process.env.ZY_GITHUB_REDIRECT_URI || 'http://localhost:3800/auth/github/callback';
|
|||
|
|
|
|||
|
|
// ─── Notion OAuth 配置 ───
|
|||
|
|
const NOTION_CLIENT_ID = process.env.ZY_NOTION_CLIENT_ID;
|
|||
|
|
const NOTION_CLIENT_SECRET = process.env.ZY_NOTION_CLIENT_SECRET;
|
|||
|
|
const NOTION_REDIRECT_URI = process.env.ZY_NOTION_REDIRECT_URI || 'http://localhost:3800/auth/notion/callback';
|
|||
|
|
|
|||
|
|
// ─── 内存存储 ───
|
|||
|
|
const pendingStates = new Map(); // state → { provider, createdAt }
|
|||
|
|
const activeSessions = new Map(); // token → { email, provider, createdAt, expiresAt }
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成安全state参数(防CSRF)
|
|||
|
|
*/
|
|||
|
|
function generateState() {
|
|||
|
|
return crypto.randomBytes(16).toString('hex');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成安全session token
|
|||
|
|
*/
|
|||
|
|
function generateToken() {
|
|||
|
|
return crypto.randomBytes(TOKEN_BYTES).toString('hex');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* GitHub OAuth 授权URL
|
|||
|
|
*/
|
|||
|
|
function getGitHubAuthUrl() {
|
|||
|
|
if (!GITHUB_CLIENT_ID) throw new Error('GitHub OAuth未配置: 需要ZY_GITHUB_CLIENT_ID环境变量');
|
|||
|
|
|
|||
|
|
const state = generateState();
|
|||
|
|
const url = new URL('https://github.com/login/oauth/authorize');
|
|||
|
|
url.searchParams.set('client_id', GITHUB_CLIENT_ID);
|
|||
|
|
url.searchParams.set('redirect_uri', GITHUB_REDIRECT_URI);
|
|||
|
|
url.searchParams.set('state', state);
|
|||
|
|
url.searchParams.set('scope', 'user:email');
|
|||
|
|
|
|||
|
|
pendingStates.set(state, {
|
|||
|
|
provider: 'github',
|
|||
|
|
createdAt: Date.now()
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return url.toString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Notion OAuth 授权URL
|
|||
|
|
*/
|
|||
|
|
function getNotionAuthUrl() {
|
|||
|
|
if (!NOTION_CLIENT_ID) throw new Error('Notion OAuth未配置: 需要ZY_NOTION_CLIENT_ID环境变量');
|
|||
|
|
|
|||
|
|
const state = generateState();
|
|||
|
|
const url = new URL('https://api.notion.com/v1/oauth/authorize');
|
|||
|
|
url.searchParams.set('client_id', NOTION_CLIENT_ID);
|
|||
|
|
url.searchParams.set('redirect_uri', NOTION_REDIRECT_URI);
|
|||
|
|
url.searchParams.set('state', state);
|
|||
|
|
url.searchParams.set('response_type', 'code');
|
|||
|
|
|
|||
|
|
pendingStates.set(state, {
|
|||
|
|
provider: 'notion',
|
|||
|
|
createdAt: Date.now()
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return url.toString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 验证state参数
|
|||
|
|
*/
|
|||
|
|
function validateState(state, provider) {
|
|||
|
|
if (!state || !provider) return false;
|
|||
|
|
|
|||
|
|
const pending = pendingStates.get(state);
|
|||
|
|
if (!pending) return false;
|
|||
|
|
|
|||
|
|
// 检查provider匹配
|
|||
|
|
if (pending.provider !== provider) return false;
|
|||
|
|
|
|||
|
|
// 检查过期(10分钟)
|
|||
|
|
if (Date.now() - pending.createdAt > 10 * 60 * 1000) {
|
|||
|
|
pendingStates.delete(state);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证通过后清理state
|
|||
|
|
pendingStates.delete(state);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* GitHub OAuth 回调处理
|
|||
|
|
*/
|
|||
|
|
async function handleGitHubCallback(code, state) {
|
|||
|
|
if (!validateState(state, 'github')) {
|
|||
|
|
throw new Error('无效的state参数');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) {
|
|||
|
|
throw new Error('GitHub OAuth未配置');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 1. 获取access token
|
|||
|
|
const tokenRes = await axios.post(
|
|||
|
|
'https://github.com/login/oauth/access_token',
|
|||
|
|
{
|
|||
|
|
client_id: GITHUB_CLIENT_ID,
|
|||
|
|
client_secret: GITHUB_CLIENT_SECRET,
|
|||
|
|
code,
|
|||
|
|
redirect_uri: GITHUB_REDIRECT_URI
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
headers: { Accept: 'application/json' }
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (!tokenRes.data.access_token) {
|
|||
|
|
throw new Error('GitHub token获取失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 获取用户邮箱
|
|||
|
|
const userRes = await axios.get('https://api.github.com/user/emails', {
|
|||
|
|
headers: {
|
|||
|
|
Authorization: `token ${tokenRes.data.access_token}`,
|
|||
|
|
Accept: 'application/vnd.github.v3+json'
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 3. 查找主邮箱
|
|||
|
|
const primaryEmail = userRes.data.find(e => e.primary)?.email;
|
|||
|
|
if (!primaryEmail) {
|
|||
|
|
throw new Error('无法获取GitHub主邮箱');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 主权验证
|
|||
|
|
const normalizedEmail = primaryEmail.trim().toLowerCase();
|
|||
|
|
if (normalizedEmail !== OWNER_EMAIL) {
|
|||
|
|
throw new Error('此频道为专属频道,仅限频道主权持有者登录');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 创建session
|
|||
|
|
const token = generateToken();
|
|||
|
|
const expiresAt = Date.now() + SESSION_EXPIRE_MS;
|
|||
|
|
|
|||
|
|
activeSessions.set(token, {
|
|||
|
|
email: normalizedEmail,
|
|||
|
|
provider: 'github',
|
|||
|
|
createdAt: Date.now(),
|
|||
|
|
expiresAt
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
token,
|
|||
|
|
email: normalizedEmail,
|
|||
|
|
provider: 'github',
|
|||
|
|
expiresAt: new Date(expiresAt).toISOString()
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Notion OAuth 回调处理
|
|||
|
|
*/
|
|||
|
|
async function handleNotionCallback(code, state) {
|
|||
|
|
if (!validateState(state, 'notion')) {
|
|||
|
|
throw new Error('无效的state参数');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!NOTION_CLIENT_ID || !NOTION_CLIENT_SECRET) {
|
|||
|
|
throw new Error('Notion OAuth未配置');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 1. 获取access token
|
|||
|
|
const tokenRes = await axios.post(
|
|||
|
|
'https://api.notion.com/v1/oauth/token',
|
|||
|
|
{
|
|||
|
|
grant_type: 'authorization_code',
|
|||
|
|
code,
|
|||
|
|
redirect_uri: NOTION_REDIRECT_URI
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
auth: {
|
|||
|
|
username: NOTION_CLIENT_ID,
|
|||
|
|
password: NOTION_CLIENT_SECRET
|
|||
|
|
},
|
|||
|
|
headers: { 'Content-Type': 'application/json' }
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (!tokenRes.data.access_token) {
|
|||
|
|
throw new Error('Notion token获取失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 获取用户信息
|
|||
|
|
const userRes = await axios.get('https://api.notion.com/v1/users/me', {
|
|||
|
|
headers: {
|
|||
|
|
Authorization: `Bearer ${tokenRes.data.access_token}`,
|
|||
|
|
'Notion-Version': '2022-06-28'
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 3. 提取邮箱
|
|||
|
|
const email = userRes.data.bot?.owner?.user?.email || userRes.data.person?.email;
|
|||
|
|
if (!email) {
|
|||
|
|
throw new Error('无法获取Notion邮箱');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 主权验证
|
|||
|
|
const normalizedEmail = email.trim().toLowerCase();
|
|||
|
|
if (normalizedEmail !== OWNER_EMAIL) {
|
|||
|
|
throw new Error('此频道为专属频道,仅限频道主权持有者登录');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 创建session
|
|||
|
|
const token = generateToken();
|
|||
|
|
const expiresAt = Date.now() + SESSION_EXPIRE_MS;
|
|||
|
|
|
|||
|
|
activeSessions.set(token, {
|
|||
|
|
email: normalizedEmail,
|
|||
|
|
provider: 'notion',
|
|||
|
|
createdAt: Date.now(),
|
|||
|
|
expiresAt
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
token,
|
|||
|
|
email: normalizedEmail,
|
|||
|
|
provider: 'notion',
|
|||
|
|
expiresAt: new Date(expiresAt).toISOString()
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 验证session token
|
|||
|
|
*/
|
|||
|
|
function validateSession(token) {
|
|||
|
|
if (!token || typeof token !== 'string') {
|
|||
|
|
return { valid: false };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const session = activeSessions.get(token);
|
|||
|
|
if (!session) {
|
|||
|
|
return { valid: false };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Date.now() > session.expiresAt) {
|
|||
|
|
activeSessions.delete(token);
|
|||
|
|
return { valid: false };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
valid: true,
|
|||
|
|
email: session.email,
|
|||
|
|
provider: session.provider,
|
|||
|
|
expiresAt: new Date(session.expiresAt).toISOString()
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 注销session
|
|||
|
|
*/
|
|||
|
|
function revokeSession(token) {
|
|||
|
|
activeSessions.delete(token);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
getGitHubAuthUrl,
|
|||
|
|
getNotionAuthUrl,
|
|||
|
|
handleGitHubCallback,
|
|||
|
|
handleNotionCallback,
|
|||
|
|
validateSession,
|
|||
|
|
revokeSession
|
|||
|
|
};
|