guanghulab/server/app/modules/email-auth.js
铸渊 ICE-GL-ZY001 · 冰朔 TCS-0002∞ 4f399b37a3
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
TCS-VRF-FINAL-20260704-130500-D1640002 · ICE-GL-ZY001 · 国作登字-2026-A-00037559
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
⊢ 心跳不停 · 闭环继续 · 安全升级完成
2026-07-04 05:09:31 +00:00

405 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ═══════════════════════════════════════════════════════════
* 📧 零点原核频道 · 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
};