190 lines
12 KiB
JavaScript
190 lines
12 KiB
JavaScript
'use strict';
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const net = require('net');
|
|
const random = () => crypto.randomBytes(32).toString('hex');
|
|
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
|
|
// One Node process owns this embedded store. No credentials or identity are accepted
|
|
// from a report request; the owner always comes from a server-created session.
|
|
module.exports = function createAccounts(root, env = process.env) {
|
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
const dbFile = path.join(root, 'identities.json');
|
|
const snapshots = path.join(root, 'reports');
|
|
fs.mkdirSync(snapshots, { recursive: true, mode: 0o700 });
|
|
let db;
|
|
try { db = JSON.parse(fs.readFileSync(dbFile, 'utf8')); }
|
|
catch (e) { if (e.code !== 'ENOENT') throw e; db = { users: [], sessions: {}, states: {} }; }
|
|
function atomic(file, value) {
|
|
const tmp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
|
|
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
function save() { atomic(dbFile, db); }
|
|
let base = null;
|
|
try { base = new URL(env.PUBLIC_BASE_URL); } catch (_) { /* local mode */ }
|
|
const prefix = base ? base.pathname.replace(/\/?$/, '/') : '/';
|
|
if (base) { base.pathname = prefix; base.search = ''; base.hash = ''; }
|
|
const secure = !!(base && base.protocol === 'https:');
|
|
const guestSessions = new Map(); // No guest identities/sessions are written to disk.
|
|
const appId = env.WECHAT_APP_ID || '';
|
|
const secret = env.WECHAT_APP_SECRET || '';
|
|
const available = !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
|
|
function cookies(req) {
|
|
return Object.fromEntries((req.headers.cookie || '').split(';').map(s => s.trim().split('=')));
|
|
}
|
|
function cookie(res, name, value, age) {
|
|
const line = `${name}=${value}; Path=${prefix}; HttpOnly; SameSite=Lax${age == null ? '' : '; Max-Age=' + age}${secure ? '; Secure' : ''}`;
|
|
const old = res.getHeader('Set-Cookie') || [];
|
|
res.setHeader('Set-Cookie', [].concat(old, line));
|
|
}
|
|
function session(req) {
|
|
const token = cookies(req).juzi_session;
|
|
const guest = token && guestSessions.get(hash(token));
|
|
if (guest && guest.expires > Date.now()) return guest.user;
|
|
const row = token && db.sessions[hash(token)];
|
|
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'wechat') : null;
|
|
}
|
|
function login(res, user) {
|
|
for (const key of Object.keys(db.sessions)) if (db.sessions[key].expires < Date.now()) delete db.sessions[key];
|
|
const token = random();
|
|
db.sessions[hash(token)] = { user: user.id, expires: Date.now() + 30 * 86400000 };
|
|
cookie(res, 'juzi_session', token, 30 * 86400);
|
|
save();
|
|
}
|
|
function publicUser(u) { return u ? { id: u.id, kind: u.kind } : null; }
|
|
function json(res, status, obj) {
|
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify(obj));
|
|
}
|
|
async function body(req, limit = 4096) {
|
|
let size = 0; const parts = [];
|
|
for await (const part of req) { size += part.length; if (size > limit) throw new Error('请求过大'); parts.push(part); }
|
|
return JSON.parse(Buffer.concat(parts).toString() || '{}');
|
|
}
|
|
function sameOrigin(req) {
|
|
if (!req.headers.origin) return true;
|
|
try {
|
|
const origin = new URL(req.headers.origin);
|
|
return origin.origin === (base ? base.origin : `http://${req.headers.host}`);
|
|
} catch (_) { return false; }
|
|
}
|
|
const limits = new Map();
|
|
function rate(req) {
|
|
let key = req.socket.remoteAddress;
|
|
// Opt in only behind a loopback proxy that OVERWRITES X-Real-IP.
|
|
const forwarded = req.headers['x-real-ip'];
|
|
if (env.TRUST_LOOPBACK_PROXY === '1' && ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(key)
|
|
&& typeof forwarded === 'string' && net.isIP(forwarded)) key = forwarded;
|
|
const now = Date.now();
|
|
for (const [k, v] of limits) if (now - v.time > 60000) limits.delete(k);
|
|
const value = limits.get(key) || { time: now, count: 0 };
|
|
limits.set(key, value); return ++value.count <= 60;
|
|
}
|
|
function redirect(res, suffix) { res.writeHead(302, { Location: prefix + suffix, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); res.end(); }
|
|
// Operational events only: never log code/state, cookies, OpenID, secrets or tokens.
|
|
function wechatEvent(event, fields = {}) { console.log('[auth/wechat] ' + event + ' ' + JSON.stringify(fields)); }
|
|
async function route(req, res, pathname) {
|
|
if (!pathname.startsWith('/api/auth/') && !pathname.startsWith('/api/reports')) return false;
|
|
try {
|
|
if (req.method !== 'GET' && !sameOrigin(req)) { json(res, 403, { ok: false, message: '请求来源不匹配' }); return true; }
|
|
if (pathname === '/api/auth/config' && req.method === 'GET') {
|
|
json(res, 200, { ok: true, wechat: available, fallback: 'browser', reason: available ? null : '当前环境尚未开通微信登录,可使用游客登录' });
|
|
} else if (pathname === '/api/auth/session' && req.method === 'GET') {
|
|
json(res, 200, { ok: true, user: publicUser(session(req)) });
|
|
} else if (pathname === '/api/auth/browser' && req.method === 'POST') {
|
|
if (!rate(req)) { json(res, 429, { ok: false, message: '操作频繁,请稍后再试' }); return true; }
|
|
const data = await body(req);
|
|
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
|
|
// Old persistent guest tokens cannot restore identity or history.
|
|
if (data.token) { json(res, 401, { ok: false, message: '游客身份已失效,请重新登录' }); return true; }
|
|
for (const [key, value] of guestSessions) if (value.expires < Date.now()) guestSessions.delete(key);
|
|
const previous = cookies(req).juzi_session;
|
|
if (previous) guestSessions.delete(hash(previous));
|
|
const token = random(), user = { id: crypto.randomUUID(), kind: 'browser' };
|
|
guestSessions.set(hash(token), { user, expires: Date.now() + 8 * 3600000 });
|
|
cookie(res, 'juzi_session', token, null);
|
|
json(res, 200, { ok: true, user: publicUser(user) });
|
|
} else if (pathname === '/api/auth/logout' && req.method === 'POST') {
|
|
const token = cookies(req).juzi_session;
|
|
if (token) {
|
|
guestSessions.delete(hash(token));
|
|
if (db.sessions[hash(token)]) { delete db.sessions[hash(token)]; save(); }
|
|
}
|
|
cookie(res, 'juzi_session', '', 0); json(res, 200, { ok: true });
|
|
} else if (pathname === '/api/auth/wechat' && req.method === 'POST') {
|
|
const data = await body(req);
|
|
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
|
|
if (!available) { json(res, 503, { ok: false, message: '微信登录尚未配置,请使用游客登录' }); return true; }
|
|
if (!rate(req)) { json(res, 429, { ok: false, message: '操作频繁,请稍后重试' }); return true; }
|
|
const state = random(), binding = random();
|
|
for (const k of Object.keys(db.states)) if (db.states[k].expires < Date.now()) delete db.states[k];
|
|
db.states[hash(state)] = { binding: hash(binding), expires: Date.now() + 600000 };
|
|
cookie(res, 'juzi_oauth', binding, 600); save();
|
|
const auth = new URL('https://open.weixin.qq.com/connect/oauth2/authorize');
|
|
auth.search = new URLSearchParams({ appid: appId, redirect_uri: new URL('api/auth/wechat/callback', base).href, response_type: 'code', scope: 'snsapi_base', state }).toString();
|
|
auth.hash = 'wechat_redirect';
|
|
json(res, 200, { ok: true, url: auth.href });
|
|
} else if (pathname === '/api/auth/wechat/callback' && req.method === 'GET') {
|
|
const url = new URL(req.url, 'http://localhost');
|
|
const state = url.searchParams.get('state') || '', code = url.searchParams.get('code');
|
|
const row = db.states[hash(state)], binding = cookies(req).juzi_oauth || '';
|
|
delete db.states[hash(state)]; save(); cookie(res, 'juzi_oauth', '', 0);
|
|
if (!available || !code || !row || row.expires < Date.now() || row.binding !== hash(binding)) {
|
|
wechatEvent('callback_rejected', { reason: 'state' }); redirect(res, '?login_error=state'); return true;
|
|
}
|
|
const tokenUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
|
tokenUrl.search = new URLSearchParams({ appid: appId, secret, code, grant_type: 'authorization_code' }).toString();
|
|
let data, upstreamStatus;
|
|
try { const r = await fetch(tokenUrl, { signal: AbortSignal.timeout(10000) }); upstreamStatus = r.status; data = await r.json(); }
|
|
catch (_) { wechatEvent('exchange_failed', { reason: 'network' }); redirect(res, '?login_error=network'); return true; }
|
|
if (!data || typeof data.openid !== 'string' || !data.openid || data.errcode || (upstreamStatus && upstreamStatus !== 200)) {
|
|
wechatEvent('exchange_failed', { reason: 'wechat', errcode: data && Number.isInteger(data.errcode) ? data.errcode : null,
|
|
httpStatus: upstreamStatus || null });
|
|
redirect(res, '?login_error=wechat'); return true;
|
|
}
|
|
const identity = hash(appId + ':' + data.openid);
|
|
let user = db.users.find(u => u.kind === 'wechat' && u.identity === identity);
|
|
const newAccount = !user;
|
|
if (!user) { user = { id: crypto.randomUUID(), kind: 'wechat', identity, consent: new Date().toISOString(), reports: [] }; db.users.push(user); }
|
|
login(res, user); wechatEvent('login_success', { newAccount }); redirect(res, '?login=success');
|
|
} else if (pathname.startsWith('/api/reports') && req.method === 'GET') {
|
|
const user = session(req);
|
|
if (!user) { json(res, 401, { ok: false, message: '请先登录' }); return true; }
|
|
const id = pathname.slice('/api/reports/'.length);
|
|
if (user.kind !== 'wechat') {
|
|
json(res, pathname === '/api/reports' ? 200 : 404,
|
|
pathname === '/api/reports' ? { ok: true, total: 0, items: [] } : { ok: false, message: '游客模式不保存历史报告' });
|
|
return true;
|
|
}
|
|
if (pathname === '/api/reports') json(res, 200, { ok: true, total: user.reports.length, items: user.reports });
|
|
else if (/^R[\w-]+$/.test(id) && user.reports.some(r => r.id === id)) {
|
|
const file = path.join(snapshots, user.id, id + '.json');
|
|
if (!fs.existsSync(file)) json(res, 404, { ok: false, message: '报告文件暂不可用' });
|
|
else json(res, 200, { ok: true, report: JSON.parse(fs.readFileSync(file, 'utf8')) });
|
|
} else json(res, 404, { ok: false, message: '报告不存在' });
|
|
} else json(res, 404, { ok: false, message: '接口不存在' });
|
|
} catch (_) { if (!res.headersSent) json(res, 400, { ok: false, message: '请求处理失败,请稍后重试' }); }
|
|
return true;
|
|
}
|
|
function storeReport(ownerId, snapshot) {
|
|
const user = db.users.find(u => u.id === ownerId);
|
|
if (!user || user.kind !== 'wechat' || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
|
|
const dir = path.join(snapshots, user.id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
atomic(path.join(dir, snapshot.id + '.json'), snapshot);
|
|
user.reports = [{ id: snapshot.id, created_at: snapshot.created_at, total: snapshot.vm.total, skinAge: snapshot.vm.skinAgeDisplay }]
|
|
.concat(user.reports.filter(r => r.id !== snapshot.id)).slice(0, 5);
|
|
save();
|
|
}
|
|
// Internal admin use only: reuse historical copy, never regenerate it.
|
|
// Not a public route; validate the stored owner and report ID before reading.
|
|
function reportPresentation(ownerId, id) {
|
|
const user = db.users.find(u => u.id === ownerId);
|
|
if (!user || !/^R[\w-]+$/.test(id)) return null;
|
|
try { return JSON.parse(fs.readFileSync(path.join(snapshots, user.id, id + '.json'), 'utf8')).presentation || null; }
|
|
catch (_) { return null; }
|
|
}
|
|
return { route, session, storeReport, reportPresentation, sameOrigin, wechatAvailable: available };
|
|
};
|