2026-05-10 13:12:44 +08:00

356 lines
12 KiB
JavaScript
Raw Permalink 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.

/**
* ═══════════════════════════════════════════════════════════
* 📧 FTCHAT 邮箱认证 + 10 槽位占位
* ═══════════════════════════════════════════════════════════
*
* 编号: ZY-FTCHAT-AUTH-001
* 守护: 铸渊 · ICE-GL-ZY001
* 版权: 国作登字-2026-A-00037559
*
* 设计原则(冰朔指令):
* - 任意 QQ 邮箱可登录, 不预设白名单
* - 系统只数数: 10 个槽位先到先得, 满即拒
* - 同一邮箱多次登录复用同一槽位
* - 槽位状态持久化到 data/slots.json (服务器重启不丢)
*/
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
// nodemailer 懒加载: 仅当需要真正发送邮件时加载,
// 避免单测/契约校验环境下因缺包而崩溃。
let _nodemailer = null;
function getNodemailer() {
if (_nodemailer) return _nodemailer;
_nodemailer = require('nodemailer');
return _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 MAX_SLOTS = 10;
const DATA_DIR = process.env.FTCHAT_DATA_DIR || path.join(__dirname, '..', 'data');
const SLOTS_FILE = path.join(DATA_DIR, 'slots.json');
// ─── 内存存储 ───
const pendingCodes = new Map(); // emailHash → { code, createdAt, attempts, email }
const activeSessions = new Map(); // token → { emailHash, email, createdAt, expiresAt }
// ─── 槽位状态(持久化)───
// slots = { '<emailHash>': { email, first_seen, last_seen, slot_index } }
let slots = {};
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
}
function loadSlots() {
ensureDataDir();
try {
if (fs.existsSync(SLOTS_FILE)) {
const raw = fs.readFileSync(SLOTS_FILE, 'utf8');
slots = JSON.parse(raw) || {};
}
} catch (err) {
console.error('[FTCHAT Auth] slots.json 读取失败, 使用空槽位:', err.message);
slots = {};
}
}
function saveSlots() {
ensureDataDir();
try {
fs.writeFileSync(SLOTS_FILE, JSON.stringify(slots, null, 2), { mode: 0o600 });
} catch (err) {
console.error('[FTCHAT Auth] slots.json 写入失败:', err.message);
}
}
loadSlots();
// ─── 工具 ───
function emailHash(email) {
return crypto.createHash('sha256').update(email.trim().toLowerCase()).digest('hex').slice(0, 16);
}
function isValidQqEmail(email) {
if (!email || typeof email !== 'string') return false;
if (email.length > 254 || email.length < 7) 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;
// 仅接受 QQ 邮箱(@qq.com / @vip.qq.com / @foxmail.com
const domain = email.slice(atIndex + 1).toLowerCase();
return domain === 'qq.com' || domain === 'vip.qq.com' || domain === 'foxmail.com';
}
function generateCode() {
const max = 999999;
const maxValid = Math.floor(0xFFFFFFFF / (max + 1)) * (max + 1);
let num;
do {
num = crypto.randomBytes(4).readUInt32BE(0);
} while (num >= maxValid);
return String(num % (max + 1)).padStart(CODE_LENGTH, '0');
}
function generateToken() {
return crypto.randomBytes(TOKEN_BYTES).toString('hex');
}
// ─── 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 = getNodemailer().createTransport({
host: 'smtp.qq.com',
port: 465,
secure: true,
auth: { user: smtpUser, pass: smtpPass },
tls: { rejectUnauthorized: true }
});
return transporter;
}
function buildEmailHtml(code) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px;">
<div style="background: linear-gradient(135deg, #1a1d3a 0%, #2d3460 100%); color: #f0f0f5; padding: 32px; border-radius: 12px;">
<h2 style="margin: 0 0 16px; font-weight: 500;">✦ 光湖 · 微调模型内测频道</h2>
<p style="opacity: 0.85; margin: 0 0 24px;">您正在登录团队内测站6 位验证码:</p>
<div style="font-size: 32px; letter-spacing: 8px; text-align: center; padding: 16px; background: rgba(255,255,255,0.1); border-radius: 8px; font-family: monospace;">${code}</div>
<p style="opacity: 0.7; font-size: 13px; margin: 24px 0 0;">5 分钟内有效。若非本人操作请忽略。</p>
</div>
<p style="text-align: center; color: #888; font-size: 12px; margin-top: 16px;">守护: 铸渊 · 国作登字-2026-A-00037559</p>
</body></html>`;
}
// ─── 清理 ───
function cleanupExpired() {
const now = Date.now();
for (const [k, v] of pendingCodes.entries()) {
if (now - v.createdAt > CODE_EXPIRE_MS) pendingCodes.delete(k);
}
for (const [t, s] of activeSessions.entries()) {
if (now > s.expiresAt) activeSessions.delete(t);
}
}
const _cleanupTimer = setInterval(cleanupExpired, 10 * 60 * 1000);
if (_cleanupTimer && typeof _cleanupTimer.unref === 'function') _cleanupTimer.unref();
// ─── 槽位逻辑 ───
function slotsTaken() {
return Object.keys(slots).length;
}
function slotsRemaining() {
return Math.max(0, MAX_SLOTS - slotsTaken());
}
function hasSlot(hash) {
return Object.prototype.hasOwnProperty.call(slots, hash);
}
function tryClaimSlot(hash, email) {
if (hasSlot(hash)) {
slots[hash].last_seen = new Date().toISOString();
saveSlots();
return { ok: true, slot_index: slots[hash].slot_index };
}
if (slotsTaken() >= MAX_SLOTS) {
return { ok: false, reason: 'slots_full' };
}
// 找最小空闲序号
const used = new Set(Object.values(slots).map(s => s.slot_index));
let idx = 1;
while (used.has(idx)) idx++;
slots[hash] = {
email,
slot_index: idx,
first_seen: new Date().toISOString(),
last_seen: new Date().toISOString()
};
saveSlots();
return { ok: true, slot_index: idx };
}
// ─── 发送验证码 ───
async function sendCode(email) {
if (!isValidQqEmail(email)) {
return { success: false, message: '请使用 QQ 邮箱(@qq.com / @vip.qq.com / @foxmail.com' };
}
const normalized = email.trim().toLowerCase();
const hash = emailHash(normalized);
// 槽位预检查:未占位且已满 → 直接拒绝(不发邮件浪费)
if (!hasSlot(hash) && slotsTaken() >= MAX_SLOTS) {
return {
success: false,
message: '内测席位已满10/10请等待现有成员退出后再试',
slots_remaining: 0
};
}
const now = Date.now();
const existing = pendingCodes.get(hash);
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(hash, { code, createdAt: now, attempts: 0, email: normalized });
// 测试模式:跳过实际发送
if (process.env.FTCHAT_AUTH_DEV_MODE === 'true') {
console.log(`[FTCHAT Auth · DEV] 验证码 (不发邮件) ${normalized.slice(0, 3)}*** => ${code}`);
return { success: true, message: '验证码已生成(开发模式,请查看服务器日志)', slots_remaining: slotsRemaining(), dev_code: code };
}
try {
const transport = getTransporter();
const smtpUser = process.env.ZY_SMTP_USER;
await transport.sendMail({
from: `"光湖 · 微调模型内测" <${smtpUser}>`,
to: normalized,
subject: '光湖内测 · 登录验证码',
html: buildEmailHtml(code)
});
console.log(`[FTCHAT Auth] 验证码已发送: ${normalized.slice(0, 3)}***`);
return { success: true, message: '验证码已发送,请查收 QQ 邮箱', slots_remaining: slotsRemaining() };
} catch (err) {
pendingCodes.delete(hash);
const cfgErr = !process.env.ZY_SMTP_USER || !process.env.ZY_SMTP_PASS;
console.error(`[FTCHAT Auth] 发送失败: ${err.message}${cfgErr ? ' · 检查 ZY_SMTP_USER/ZY_SMTP_PASS' : ''}`);
return { success: false, message: cfgErr ? '邮件服务未配置' : '验证码发送失败,请稍后重试' };
}
}
// ─── 校验 ───
function verifyCode(email, code) {
if (!email || !code) return { success: false, message: '邮箱和验证码不能为空' };
if (!isValidQqEmail(email)) return { success: false, message: '邮箱格式不正确' };
const normalized = email.trim().toLowerCase();
const hash = emailHash(normalized);
const inputCode = String(code).trim();
const now = Date.now();
const pending = pendingCodes.get(hash);
if (!pending) return { success: false, message: '验证码不存在或已过期,请重新获取' };
if (now - pending.createdAt > CODE_EXPIRE_MS) {
pendingCodes.delete(hash);
return { success: false, message: '验证码已过期' };
}
if (pending.attempts >= CODE_MAX_ATTEMPTS) {
pendingCodes.delete(hash);
return { success: false, message: '错误次数过多,请重新获取' };
}
// 常量时间比较
const a = Buffer.from(inputCode);
const b = Buffer.from(pending.code);
const codeOk = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!codeOk) {
pending.attempts += 1;
return { success: false, message: `验证码错误(${CODE_MAX_ATTEMPTS - pending.attempts} 次机会)` };
}
pendingCodes.delete(hash);
// 占据槽位
const claim = tryClaimSlot(hash, normalized);
if (!claim.ok) {
return { success: false, message: '内测席位已满10/10无法登录' };
}
const token = generateToken();
const expiresAt = now + SESSION_EXPIRE_MS;
activeSessions.set(token, {
emailHash: hash,
email: normalized,
slot_index: claim.slot_index,
createdAt: now,
expiresAt
});
console.log(`[FTCHAT Auth] 登录成功 ${normalized.slice(0, 3)}*** · 槽位 ${claim.slot_index}/${MAX_SLOTS}`);
return {
success: true,
token,
user_hash: hash,
slot_index: claim.slot_index,
expires_at: new Date(expiresAt).toISOString(),
message: '登录成功'
};
}
function validateSession(token) {
if (!token) return { valid: false };
const s = activeSessions.get(token);
if (!s) return { valid: false };
if (Date.now() > s.expiresAt) {
activeSessions.delete(token);
return { valid: false };
}
return {
valid: true,
email: s.email,
user_hash: s.emailHash,
slot_index: s.slot_index,
expires_at: new Date(s.expiresAt).toISOString()
};
}
function revokeSession(token) {
return activeSessions.delete(token);
}
function getStatus() {
return {
slots_taken: slotsTaken(),
slots_total: MAX_SLOTS,
slots_remaining: slotsRemaining(),
sessions_active: activeSessions.size,
smtp_configured: !!(process.env.ZY_SMTP_USER && process.env.ZY_SMTP_PASS)
};
}
// ─── 测试钩子 ───
function _resetForTests() {
pendingCodes.clear();
activeSessions.clear();
slots = {};
saveSlots();
}
module.exports = {
sendCode,
verifyCode,
validateSession,
revokeSession,
getStatus,
emailHash,
isValidQqEmail,
MAX_SLOTS,
_resetForTests
};