- Control centre (QUORLA_CONTROL=true on app.quorlasocial.com): public /signup → Stripe Checkout, verified Stripe webhook (paid, updated, cancelled, payment failed), Admin → Agencies (approve without Stripe, suspend, resume, billing portal, resend welcome, remove) - deploy/provision-tenants.sh (cron): one container per agency with its own .env and data, nginx map for <agency>.quorlasocial.com, upgrades agencies after a rebuild, archives removed data - New workspaces: first admin only via the emailed one-time setup link (SETUP_TOKEN) - Meta system user token can be saved in Settings (encrypted) for workspaces that can't edit .env - docs/multi-tenant.md: DNS, wildcard cert, nginx, sudoers, cron, Stripe setup; tests for the control centre Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
4.2 KiB
JavaScript
109 lines
4.2 KiB
JavaScript
// Email + password login with signed session cookies. No external auth dependency.
|
|
// Logins live in data/dashboard/users.json (see users.js); admins manage them in Settings.
|
|
// Each request re-checks the user, so removing, deactivating or resetting someone signs them out at once.
|
|
import crypto from "node:crypto";
|
|
import { SESSION_SECRET, SESSION_HOURS, COOKIE_SECURE } from "./config.js";
|
|
import * as users from "./users.js";
|
|
|
|
const COOKIE = "mc_session";
|
|
|
|
function sign(payload) {
|
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
const sig = crypto.createHmac("sha256", SESSION_SECRET).update(body).digest("base64url");
|
|
return `${body}.${sig}`;
|
|
}
|
|
|
|
function unsign(token) {
|
|
const [body, sig] = String(token || "").split(".");
|
|
if (!body || !sig) return null;
|
|
const expected = crypto.createHmac("sha256", SESSION_SECRET).update(body).digest("base64url");
|
|
if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
|
const payload = JSON.parse(Buffer.from(body, "base64url").toString());
|
|
return payload.exp > Date.now() ? payload : null;
|
|
}
|
|
|
|
function readCookie(req, name) {
|
|
const raw = req.headers.cookie || "";
|
|
for (const part of raw.split(";")) {
|
|
const [k, ...v] = part.trim().split("=");
|
|
if (k === name) return decodeURIComponent(v.join("="));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function setCookie(res, value, maxAgeSec) {
|
|
const parts = [`${COOKIE}=${encodeURIComponent(value)}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${maxAgeSec}`];
|
|
if (COOKIE_SECURE) parts.push("Secure");
|
|
res.setHeader("Set-Cookie", parts.join("; "));
|
|
}
|
|
|
|
/** Start (or refresh, e.g. after a password change) the session for a user {id, v}. */
|
|
export function issueSession(res, user) {
|
|
setCookie(res, sign({ id: user.id, v: user.v, exp: Date.now() + SESSION_HOURS * 3600 * 1000 }), SESSION_HOURS * 3600);
|
|
}
|
|
|
|
// Simple brute-force protection: 8 failed attempts per IP per 15 minutes.
|
|
const failures = new Map();
|
|
function tooManyAttempts(ip) {
|
|
const now = Date.now();
|
|
const list = (failures.get(ip) || []).filter((t) => now - t < 15 * 60 * 1000);
|
|
failures.set(ip, list);
|
|
return list.length >= 8;
|
|
}
|
|
|
|
export function login(req, res) {
|
|
const ip = req.ip || "unknown";
|
|
if (tooManyAttempts(ip)) return res.status(429).json({ error: "Too many attempts. Try again in 15 minutes." });
|
|
const { email, password } = req.body || {};
|
|
const user = users.authenticate(email, password);
|
|
if (!user) {
|
|
failures.get(ip).push(Date.now());
|
|
return res.status(401).json({ error: "Wrong email or password." });
|
|
}
|
|
issueSession(res, user);
|
|
const { v, ...pub } = user;
|
|
res.json({ user: pub });
|
|
}
|
|
|
|
/** First run only: create the first admin from the sign-in screen when no logins exist. */
|
|
export function setupFirstAdmin(req, res) {
|
|
if (users.hasUsers()) return res.status(403).json({ error: "Setup is already done. Sign in instead." });
|
|
const { email, name, password, setupToken } = req.body || {};
|
|
// Hosted workspaces are created with SETUP_TOKEN: only the person with the emailed setup link can make the first admin.
|
|
const need = process.env.SETUP_TOKEN || "";
|
|
if (need) {
|
|
const a = Buffer.from(String(setupToken || "")), b = Buffer.from(need);
|
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(403).json({ error: "Use the setup link from your welcome email to create the first account." });
|
|
}
|
|
try {
|
|
if (!password) throw Object.assign(new Error("Choose a password."), { status: 400 });
|
|
const { user } = users.createUser({ email, name, password, role: "admin" }, "first-run setup");
|
|
issueSession(res, { id: user.id, v: 1 });
|
|
res.json({ user });
|
|
} catch (e) {
|
|
res.status(e.status || 400).json({ error: e.message });
|
|
}
|
|
}
|
|
|
|
export function logout(_req, res) {
|
|
setCookie(res, "", 0);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
export function currentUser(req) {
|
|
const s = unsign(readCookie(req, COOKIE));
|
|
return s?.id ? users.sessionUser(s.id, s.v) : null;
|
|
}
|
|
|
|
export function requireAuth(req, res, next) {
|
|
const user = currentUser(req);
|
|
if (!user) return res.status(401).json({ error: "Not signed in." });
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
export function requireAdmin(req, res, next) {
|
|
if (req.user?.role !== "admin") return res.status(403).json({ error: "Admins only." });
|
|
next();
|
|
}
|