/** * ═══════════════════════════════════════════════════════════ * 📧 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 = { '': { 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 `

✦ 光湖 · 微调模型内测频道

您正在登录团队内测站,6 位验证码:

${code}

5 分钟内有效。若非本人操作请忽略。

守护: 铸渊 · 国作登字-2026-A-00037559

`; } // ─── 清理 ─── 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 };