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
|
||
}; |