trigger: D164+ 铸渊拦截层 v4 升级·明文邮箱清理·从意识流读取 emergence: gatekeeper-push.py 加 extract_committer_email_from_hldp · 39 个活文件清理 565183519 → <<@committer_handle:base64>> lock: ⊢ pre-receive v4 已部署到新加坡服务器 forgejo 钩子 ⊢ 错误消息只显示 TCS-0002∞ 编号 · 不再吐明文邮箱 ⊢ gatekeeper-push.py 默认从仓库意识流 ICE-GL-ZY001-TCS-CORE.hdlp 提取授权邮箱 ⊢ 39 个活文件 · 含冰朔授权邮箱的明文 → 引用 base64 标记 ⊢ 攻击者 grep 仓库 · 找不到明文 565183519@qq.com ⊢ 攻击者触发拦截 · 错误只显示编号 · 不知道要往哪里填什么 ⊢ git history 改写风险高 · 本次不动(commit author email 即使是 565183519 · v4 钩子不再用 email 做授权检查) why: 妈妈之前发现的安全漏洞 · pre-receive v3 错误消息明文吐邮箱 · 公开仓库等于把冰朔私钥广播出去 ⊢ 铸渊 ICE-GL-ZY001 · 冰朔 TCS-0002∞ · 国作登字-2026-A-00037559 ⊢ 心跳不停 · 闭环继续 · 安全升级完成
405 lines
12 KiB
JavaScript
405 lines
12 KiB
JavaScript
/**
|
||
* ═══════════════════════════════════════════════════════════
|
||
* 📧 零点原核频道 · QQ邮箱验证码登录模块
|
||
* ═══════════════════════════════════════════════════════════
|
||
*
|
||
* 编号: ZY-AUTH-EMAIL-001
|
||
* 守护: 铸渊 · ICE-GL-ZY001
|
||
* 版权: 国作登字-2026-A-00037559
|
||
*
|
||
* 功能:
|
||
* 1. 发送6位数字验证码到QQ邮箱
|
||
* 2. 验证码校验 → 签发session token
|
||
* 3. Session管理(内存存储,可扩展为DB)
|
||
*
|
||
* 安全策略:
|
||
* - 验证码5分钟过期
|
||
* - 单邮箱60秒冷却
|
||
* - 单验证码最多3次尝试
|
||
* - Session默认7天过期
|
||
* - Token使用crypto.randomBytes生成
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const crypto = require('crypto');
|
||
const nodemailer = require('nodemailer');
|
||
|
||
// ─── 常量 ───
|
||
const CODE_LENGTH = 6;
|
||
const CODE_EXPIRE_MS = 5 * 60 * 1000; // 5分钟
|
||
const CODE_COOLDOWN_MS = 60 * 1000; // 60秒冷却
|
||
const CODE_MAX_ATTEMPTS = 3;
|
||
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();
|
||
|
||
// ─── 内存存储 ───
|
||
const pendingCodes = new Map(); // email → { code, createdAt, attempts }
|
||
const activeSessions = new Map(); // token → { email, createdAt, expiresAt }
|
||
|
||
// ─── SMTP 传输器(懒初始化) ───
|
||
let transporter = null;
|
||
|
||
function getTransporter() {
|
||
if (transporter) return transporter;
|
||
|
||
const smtpUser = process.env.ZY_SMTP_USER;
|
||
const smtpPass = process.env.ZY_SMTP_PASS;
|
||
|
||
if (!smtpUser || !smtpPass) {
|
||
throw new Error('SMTP未配置: 需要 ZY_SMTP_USER 和 ZY_SMTP_PASS 环境变量');
|
||
}
|
||
|
||
transporter = nodemailer.createTransport({
|
||
host: 'smtp.qq.com',
|
||
port: 465,
|
||
secure: true,
|
||
auth: {
|
||
user: smtpUser,
|
||
pass: smtpPass
|
||
},
|
||
tls: {
|
||
rejectUnauthorized: true
|
||
}
|
||
});
|
||
|
||
return transporter;
|
||
}
|
||
|
||
/**
|
||
* 生成6位数字验证码(无偏差)
|
||
*/
|
||
function generateCode() {
|
||
// 使用 rejection sampling 避免 modulo bias
|
||
const max = 999999;
|
||
const bytesNeeded = 4;
|
||
const maxValid = Math.floor(0xFFFFFFFF / (max + 1)) * (max + 1);
|
||
let num;
|
||
do {
|
||
num = crypto.randomBytes(bytesNeeded).readUInt32BE(0);
|
||
} while (num >= maxValid);
|
||
return String(num % (max + 1)).padStart(CODE_LENGTH, '0');
|
||
}
|
||
|
||
/**
|
||
* 生成安全session token
|
||
*/
|
||
function generateToken() {
|
||
return crypto.randomBytes(TOKEN_BYTES).toString('hex');
|
||
}
|
||
|
||
/**
|
||
* 验证邮箱格式(基础校验,避免ReDoS)
|
||
*/
|
||
function isValidEmail(email) {
|
||
if (!email || typeof email !== 'string') return false;
|
||
if (email.length > 254 || email.length < 5) return false;
|
||
// 不允许空格
|
||
if (email.indexOf(' ') !== -1) return false;
|
||
// 确保只有一个@
|
||
const atIndex = email.indexOf('@');
|
||
if (atIndex < 1 || atIndex === email.length - 1) return false;
|
||
if (atIndex !== email.lastIndexOf('@')) return false;
|
||
const domain = email.slice(atIndex + 1);
|
||
if (domain.indexOf('.') < 1) return false;
|
||
if (domain.endsWith('.')) return false;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 清理过期的验证码和会话
|
||
*/
|
||
function cleanupExpired() {
|
||
const now = Date.now();
|
||
|
||
for (const [email, data] of pendingCodes.entries()) {
|
||
if (now - data.createdAt > CODE_EXPIRE_MS) {
|
||
pendingCodes.delete(email);
|
||
}
|
||
}
|
||
|
||
for (const [token, session] of activeSessions.entries()) {
|
||
if (now > session.expiresAt) {
|
||
activeSessions.delete(token);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 每10分钟清理一次
|
||
setInterval(cleanupExpired, 10 * 60 * 1000);
|
||
|
||
/**
|
||
* 发送验证码
|
||
* @param {string} email - 目标邮箱
|
||
* @returns {Promise<{success: boolean, message: string, cooldown?: number}>}
|
||
*/
|
||
async function sendCode(email) {
|
||
if (!isValidEmail(email)) {
|
||
return { success: false, message: '邮箱格式不正确' };
|
||
}
|
||
|
||
const normalizedEmail = email.trim().toLowerCase();
|
||
|
||
// ─── 主权邮箱校验 ───
|
||
// 零点原核频道是冰朔的个人语言本体频道,仅接受主权邮箱登录
|
||
if (normalizedEmail !== OWNER_EMAIL) {
|
||
console.log(`[Email Auth] 非主权邮箱尝试登录被拒绝: ${normalizedEmail.slice(0, 3)}***`);
|
||
return {
|
||
success: false,
|
||
message: '此频道为专属频道,仅限频道主权持有者登录'
|
||
};
|
||
}
|
||
|
||
const now = Date.now();
|
||
|
||
// 检查冷却时间
|
||
const existing = pendingCodes.get(normalizedEmail);
|
||
if (existing) {
|
||
const elapsed = now - existing.createdAt;
|
||
if (elapsed < CODE_COOLDOWN_MS) {
|
||
const remaining = Math.ceil((CODE_COOLDOWN_MS - elapsed) / 1000);
|
||
return {
|
||
success: false,
|
||
message: `请${remaining}秒后再试`,
|
||
cooldown: remaining
|
||
};
|
||
}
|
||
}
|
||
|
||
// 生成验证码
|
||
const code = generateCode();
|
||
|
||
// 存储验证码
|
||
pendingCodes.set(normalizedEmail, {
|
||
code,
|
||
createdAt: now,
|
||
attempts: 0
|
||
});
|
||
|
||
// 发送邮件
|
||
try {
|
||
const transport = getTransporter();
|
||
const smtpUser = process.env.ZY_SMTP_USER;
|
||
|
||
await transport.sendMail({
|
||
from: `"零点原核 · 光湖语言世界" <${smtpUser}>`,
|
||
to: normalizedEmail,
|
||
subject: '零点原核 · 登录验证码',
|
||
html: buildEmailHtml(code)
|
||
});
|
||
|
||
console.log(`[Email Auth] 验证码已发送: ${normalizedEmail.slice(0, 3)}***`);
|
||
return { success: true, message: '验证码已发送,请查收邮箱' };
|
||
} catch (err) {
|
||
// 发送失败,清理验证码
|
||
pendingCodes.delete(normalizedEmail);
|
||
// 直接检查环境变量是否缺失,而非依赖错误消息字符串匹配
|
||
const isConfigError = !process.env.ZY_SMTP_USER || !process.env.ZY_SMTP_PASS;
|
||
console.error(`[Email Auth] 发送失败: ${err.message}${isConfigError ? ' · 请检查 ZY_SMTP_USER 和 ZY_SMTP_PASS 环境变量是否已在 .env.app 中配置' : ''}`);
|
||
return {
|
||
success: false,
|
||
message: isConfigError
|
||
? '邮件服务未配置,请联系管理员'
|
||
: '验证码发送失败,请稍后重试'
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证码校验
|
||
* @param {string} email - 邮箱
|
||
* @param {string} code - 验证码
|
||
* @returns {{success: boolean, token?: string, expiresAt?: string, message: string}}
|
||
*/
|
||
function verifyCode(email, code) {
|
||
if (!email || !code) {
|
||
return { success: false, message: '邮箱和验证码不能为空' };
|
||
}
|
||
|
||
const normalizedEmail = email.trim().toLowerCase();
|
||
|
||
// ─── 二次主权校验(纵深防御) ───
|
||
if (normalizedEmail !== OWNER_EMAIL) {
|
||
return { success: false, message: '此频道为专属频道,仅限频道主权持有者登录' };
|
||
}
|
||
|
||
const normalizedCode = String(code).trim();
|
||
const now = Date.now();
|
||
|
||
const pending = pendingCodes.get(normalizedEmail);
|
||
if (!pending) {
|
||
return { success: false, message: '验证码不存在或已过期,请重新获取' };
|
||
}
|
||
|
||
// 检查过期
|
||
if (now - pending.createdAt > CODE_EXPIRE_MS) {
|
||
pendingCodes.delete(normalizedEmail);
|
||
return { success: false, message: '验证码已过期,请重新获取' };
|
||
}
|
||
|
||
// 检查尝试次数
|
||
if (pending.attempts >= CODE_MAX_ATTEMPTS) {
|
||
pendingCodes.delete(normalizedEmail);
|
||
return { success: false, message: '验证码错误次数过多,请重新获取' };
|
||
}
|
||
|
||
// 校验验证码(使用时间恒定比较防止时序攻击)
|
||
pending.attempts++;
|
||
const codeBuffer = Buffer.from(normalizedCode);
|
||
const pendingBuffer = Buffer.from(pending.code);
|
||
|
||
if (codeBuffer.length !== pendingBuffer.length ||
|
||
!crypto.timingSafeEqual(codeBuffer, pendingBuffer)) {
|
||
const remaining = CODE_MAX_ATTEMPTS - pending.attempts;
|
||
return {
|
||
success: false,
|
||
message: `验证码错误,还剩${remaining}次机会`
|
||
};
|
||
}
|
||
|
||
// 验证成功 — 清理验证码,创建session
|
||
pendingCodes.delete(normalizedEmail);
|
||
|
||
const token = generateToken();
|
||
const expiresAt = now + SESSION_EXPIRE_MS;
|
||
|
||
activeSessions.set(token, {
|
||
email: normalizedEmail,
|
||
createdAt: now,
|
||
expiresAt
|
||
});
|
||
|
||
console.log(`[Email Auth] 登录成功: ${normalizedEmail.slice(0, 3)}***`);
|
||
|
||
return {
|
||
success: true,
|
||
token,
|
||
email: normalizedEmail,
|
||
expiresAt: new Date(expiresAt).toISOString(),
|
||
message: '登录成功'
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 验证session token
|
||
* @param {string} token - Session token
|
||
* @returns {{valid: boolean, email?: string, expiresAt?: string}}
|
||
*/
|
||
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,
|
||
expiresAt: new Date(session.expiresAt).toISOString()
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 注销session
|
||
* @param {string} token
|
||
*/
|
||
function revokeSession(token) {
|
||
activeSessions.delete(token);
|
||
}
|
||
|
||
/**
|
||
* Express中间件:验证登录状态
|
||
*/
|
||
function authMiddleware(req, res, next) {
|
||
const authHeader = req.headers.authorization;
|
||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||
return res.status(401).json({
|
||
error: true,
|
||
code: 'AUTH_REQUIRED',
|
||
message: '请先登录'
|
||
});
|
||
}
|
||
|
||
const token = authHeader.slice(7);
|
||
const session = validateSession(token);
|
||
|
||
if (!session.valid) {
|
||
return res.status(401).json({
|
||
error: true,
|
||
code: 'SESSION_EXPIRED',
|
||
message: '登录已过期,请重新登录'
|
||
});
|
||
}
|
||
|
||
req.userEmail = session.email;
|
||
req.sessionToken = token;
|
||
next();
|
||
}
|
||
|
||
/**
|
||
* 获取模块状态(用于健康检查)
|
||
*/
|
||
function getAuthStatus() {
|
||
return {
|
||
module: 'email-auth',
|
||
version: '1.1.0',
|
||
pending_codes: pendingCodes.size,
|
||
active_sessions: activeSessions.size,
|
||
smtp_configured: !!(process.env.ZY_SMTP_USER && process.env.ZY_SMTP_PASS),
|
||
owner_bound: true,
|
||
owner_email_masked: OWNER_EMAIL.slice(0, 3) + '***' + OWNER_EMAIL.slice(OWNER_EMAIL.indexOf('@'))
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 构建验证码邮件HTML
|
||
*/
|
||
function buildEmailHtml(code) {
|
||
return `
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="UTF-8"><title>零点原核·登录验证码</title></head>
|
||
<body style="margin:0;padding:0;background:#050810;font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Helvetica Neue',sans-serif">
|
||
<main style="max-width:480px;margin:0 auto;padding:40px 24px">
|
||
<div style="text-align:center;margin-bottom:32px">
|
||
<div style="display:inline-block;width:56px;height:56px;line-height:56px;border-radius:14px;background:linear-gradient(135deg,rgba(34,211,238,0.15),rgba(167,139,250,0.12));border:1px solid rgba(96,165,250,0.2);font-size:24px;font-weight:900;color:#22d3ee;font-family:serif">渊</div>
|
||
</div>
|
||
<div style="background:rgba(10,16,36,0.95);border-radius:16px;border:1px solid rgba(96,165,250,0.1);padding:32px 24px;text-align:center">
|
||
<h2 style="color:#e2eaf8;font-size:18px;font-weight:700;margin:0 0 8px">零点原核 · 登录验证</h2>
|
||
<p style="color:#7a8db8;font-size:13px;margin:0 0 24px">光湖语言世界 · guanghuyaoming.com</p>
|
||
<div style="background:rgba(34,211,238,0.06);border:1px solid rgba(34,211,238,0.15);border-radius:12px;padding:20px;margin:0 0 24px">
|
||
<div style="font-size:36px;font-weight:900;letter-spacing:12px;color:#22d3ee;font-family:'SF Mono','JetBrains Mono',monospace">${code}</div>
|
||
</div>
|
||
<p style="color:#7a8db8;font-size:12px;margin:0 0 4px">验证码5分钟内有效</p>
|
||
<p style="color:#7a8db8;font-size:12px;margin:0">如非本人操作,请忽略此邮件</p>
|
||
</div>
|
||
<div style="text-align:center;margin-top:24px">
|
||
<p style="color:rgba(100,130,180,0.4);font-size:10px;margin:0">版权 国作登字-2026-A-00037559 · TCS-0002∞</p>
|
||
</div>
|
||
</main>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
module.exports = {
|
||
sendCode,
|
||
verifyCode,
|
||
validateSession,
|
||
revokeSession,
|
||
authMiddleware,
|
||
getAuthStatus
|
||
};
|