diff --git a/server/auth.js b/server/auth.js index c27de2e..075a2be 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,10 +1,10 @@ -// HOST-Kundenkonten: Passwort-Hashing + JWT-Ausstellung/-Prüfung. -// (Das sind die Konten auf der RAPPORT-HOST-Plattform — NICHT die Endnutzer -// in den einzelnen Rapport-Instanzen.) +// HOST-Auth. Zwei getrennte Welten: +// • Kundenkonten (accounts) — Register/Login, JWT mit {sub,email} +// • Betreiber/Admin — SEPARATES Login mit ADMIN_PASSWORD, JWT mit {role:operator} +// Ein Kunde kann NIE Admin werden; Admin ist kein Kundenkonto. import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import { env } from "./env.js"; -import { one, query } from "./db.js"; export async function hashPassword(plain) { return bcrypt.hash(plain, 10); @@ -14,47 +14,39 @@ export async function verifyPassword(plain, hash) { return bcrypt.compare(plain, hash); } +// — Kunden-Token — export function signToken(account) { - return jwt.sign( - { sub: account.id, email: account.email, is_admin: !!account.is_admin }, - env.jwtSecret, - { expiresIn: "7d" } - ); + return jwt.sign({ sub: account.id, email: account.email }, env.jwtSecret, { expiresIn: "7d" }); } -// Promotet das Konto zum Admin, wenn die E-Mail ADMIN_EMAIL entspricht. -// Wird bei Register/Login aufgerufen, damit dein Konto ohne manuellen DB-Eingriff -// Admin wird. Gibt das (ggf. aktualisierte) is_admin zurück. -export async function ensureAdminFlag(account) { - const shouldBeAdmin = env.adminEmail && account.email.toLowerCase() === env.adminEmail; - if (shouldBeAdmin && !account.is_admin) { - await query("update accounts set is_admin = true where id = $1", [account.id]); - return true; - } - return !!account.is_admin; +// — Admin/Betreiber-Token (eigene Rolle, kürzere Laufzeit) — +export function signAdminToken() { + return jwt.sign({ role: "operator" }, env.jwtSecret, { expiresIn: "12h" }); } -// Express-Middleware: setzt req.account aus dem Bearer-Token oder 401. +// Middleware: eingeloggter Kunde (oder 401). export function requireAuth(req, res, next) { - const header = req.headers.authorization || ""; - const token = header.startsWith("Bearer ") ? header.slice(7) : null; + const token = (req.headers.authorization || "").replace(/^Bearer /, ""); if (!token) return res.status(401).json({ error: "Nicht angemeldet." }); try { - const payload = jwt.verify(token, env.jwtSecret); - req.account = { id: payload.sub, email: payload.email, is_admin: !!payload.is_admin }; + const p = jwt.verify(token, env.jwtSecret); + if (p.role === "operator") return res.status(401).json({ error: "Admin-Token, kein Kundenkonto." }); + req.account = { id: p.sub, email: p.email }; next(); } catch { res.status(401).json({ error: "Session ungültig oder abgelaufen." }); } } -// Wie requireAuth, aber verlangt Admin. Prüft die DB (autoritativ — nicht nur -// das Token), damit ein entzogenes Admin-Recht sofort greift. +// Middleware: Betreiber/Admin (Operator-Rolle im Token, oder 403). export function requireAdmin(req, res, next) { - requireAuth(req, res, async () => { - const row = await one("select is_admin from accounts where id = $1", [req.account.id]); - if (!row || !row.is_admin) return res.status(403).json({ error: "Kein Admin-Zugriff." }); - req.account.is_admin = true; + const token = (req.headers.authorization || "").replace(/^Bearer /, ""); + if (!token) return res.status(401).json({ error: "Nicht angemeldet." }); + try { + const p = jwt.verify(token, env.jwtSecret); + if (p.role !== "operator") return res.status(403).json({ error: "Kein Admin-Zugriff." }); next(); - }); + } catch { + res.status(401).json({ error: "Session ungültig oder abgelaufen." }); + } } diff --git a/server/env.js b/server/env.js index 7aadfe8..c13396e 100644 --- a/server/env.js +++ b/server/env.js @@ -26,8 +26,8 @@ export const env = { publicBaseUrl: (e.PUBLIC_BASE_URL || "http://localhost:8787").replace(/\/+$/, ""), jwtSecret: e.JWT_SECRET || "dev-insecure-secret-change-me", databaseUrl: e.DATABASE_URL || "postgres://rapport_host:rapport_host@localhost:55432/rapport_host", - // Konto mit dieser E-Mail wird automatisch zum Admin (Betreiber-Bereich /admin). - adminEmail: (e.ADMIN_EMAIL || "").trim().toLowerCase(), + // Passwort für den Betreiber-Bereich (/admin). Getrennt von Kundenkonten. + adminPassword: e.ADMIN_PASSWORD || "", // Gebautes public/ der RAPPORT-WEBSITE (Hugo). Default: Schwester-Repo lokal. websitePublicDir: e.WEBSITE_PUBLIC_DIR || new URL("../../RAPPORT-WEBSITE/public", import.meta.url).pathname, diff --git a/server/migrations/0003_admin.sql b/server/migrations/0003_admin.sql index a1a283a..4c5be1c 100644 --- a/server/migrations/0003_admin.sql +++ b/server/migrations/0003_admin.sql @@ -1,5 +1,6 @@ --- RAPPORT-HOST — Admin-Flag für den Betreiber-Bereich (/admin). --- Ein Konto mit is_admin = true sieht alle Kunden, Abos, Instanzen und kann --- Instanzen sperren/reaktivieren. Das eigene Konto wird automatisch promoted, --- wenn die E-Mail ADMIN_EMAIL aus der .env entspricht (siehe auth.js). -alter table accounts add column if not exists is_admin boolean not null default false; +-- RAPPORT-HOST — Admin-Modell. +-- Der Betreiber-Bereich (/admin) nutzt ein SEPARATES Login (ADMIN_PASSWORD), +-- getrennt von Kundenkonten — kein Konto-Flag nötig. Eine frühere Version +-- legte accounts.is_admin an; das ist nicht mehr in Gebrauch und wird hier +-- wieder entfernt (idempotent). +alter table accounts drop column if exists is_admin; diff --git a/server/routes/account.js b/server/routes/account.js index b28d76c..2706b97 100644 --- a/server/routes/account.js +++ b/server/routes/account.js @@ -12,7 +12,7 @@ const PROFILE_FIELDS = ["company", "contact_name", "street", "zip", "city", "cou // ── Konto-Übersicht: Konto + Profil + aktuelles Abo + alle Instanzen ───────── accountRouter.get("/me", requireAuth, async (req, res) => { const account = await one( - `select id, email, company, contact_name, street, zip, city, country, phone, is_admin, created_at + `select id, email, company, contact_name, street, zip, city, country, phone, created_at from accounts where id = $1`, [req.account.id] ); diff --git a/server/routes/admin.js b/server/routes/admin.js index 0713398..f9999e4 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -1,12 +1,24 @@ -// RAPPORT-HOST — Betreiber-Bereich (/api/admin). Nur für is_admin-Konten. -// Übersicht aller Kunden, Abos, Instanzen + Sperren/Reaktivieren. +// RAPPORT-HOST — Betreiber-Bereich (/api/admin). Separates Admin-Login +// (ADMIN_PASSWORD), getrennt von Kundenkonten. Übersicht aller Kunden, Abos, +// Instanzen + Sperren/Reaktivieren. import { Router } from "express"; import { one, query } from "../db.js"; -import { requireAdmin } from "../auth.js"; +import { requireAdmin, signAdminToken } from "../auth.js"; import { getPlan } from "../plans.js"; +import { env } from "../env.js"; export const adminRouter = Router(); +// — Separates Admin-Login (Passwort aus ADMIN_PASSWORD) — kein requireAdmin davor. +adminRouter.post("/login", (req, res) => { + if (!env.adminPassword) return res.status(503).json({ error: "Admin-Bereich nicht konfiguriert." }); + if ((req.body?.password || "") !== env.adminPassword) { + return res.status(401).json({ error: "Passwort falsch." }); + } + res.json({ token: signAdminToken() }); +}); + +// Alle folgenden Routen verlangen einen gültigen Operator-Token. adminRouter.use(requireAdmin); // — Kennzahlen fürs Dashboard — @@ -32,7 +44,7 @@ adminRouter.get("/stats", async (_req, res) => { // — Alle Kunden mit Abo + Instanzen — adminRouter.get("/accounts", async (_req, res) => { const { rows } = await query(` - select a.id, a.email, a.company, a.is_admin, a.created_at, + select a.id, a.email, a.company, a.created_at, s.plan, s.status as sub_status, s.current_period_end, coalesce(i.cnt, 0)::int as instance_count from accounts a diff --git a/server/routes/auth.js b/server/routes/auth.js index ebe96fc..1c5d979 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,36 +1,41 @@ -// HOST-Konten: Registrierung + Login. Gibt ein JWT zurück. +// HOST-Kundenkonten: Registrierung + Login. Gibt ein JWT zurück. import { Router } from "express"; import { one } from "../db.js"; -import { hashPassword, verifyPassword, signToken, ensureAdminFlag } from "../auth.js"; +import { hashPassword, verifyPassword, signToken } from "../auth.js"; export const authRouter = Router(); const isEmail = (s) => /.+@.+\..+/.test(s || ""); authRouter.post("/register", async (req, res) => { - const { email, password } = req.body || {}; + const email = (req.body?.email || "").trim().toLowerCase(); + const password = req.body?.password || ""; if (!isEmail(email)) return res.status(400).json({ error: "Ungültige Email." }); - if (!password || password.length < 8) return res.status(400).json({ error: "Passwort min. 8 Zeichen." }); + if (password.length < 8) return res.status(400).json({ error: "Passwort min. 8 Zeichen." }); - const existing = await one("select id from accounts where email = $1", [email.toLowerCase()]); + const existing = await one("select id from accounts where email = $1", [email]); if (existing) return res.status(409).json({ error: "Konto existiert bereits." }); - const account = await one( - "insert into accounts (email, password_hash) values ($1, $2) returning id, email, is_admin", - [email.toLowerCase(), await hashPassword(password)] - ); - account.is_admin = await ensureAdminFlag(account); - res.json({ token: signToken(account), account: { id: account.id, email: account.email, is_admin: account.is_admin } }); + let account; + try { + account = await one( + "insert into accounts (email, password_hash) values ($1, $2) returning id, email", + [email, await hashPassword(password)] + ); + } catch (e) { + // 23505 = unique_violation (Race zwischen SELECT und INSERT). + if (e.code === "23505") return res.status(409).json({ error: "Konto existiert bereits." }); + throw e; + } + res.json({ token: signToken(account), account: { id: account.id, email: account.email } }); }); authRouter.post("/login", async (req, res) => { - const { email, password } = req.body || {}; - const account = await one("select id, email, password_hash, is_admin from accounts where email = $1", [ - (email || "").toLowerCase(), - ]); - if (!account || !(await verifyPassword(password || "", account.password_hash))) { + const email = (req.body?.email || "").trim().toLowerCase(); + const password = req.body?.password || ""; + const account = await one("select id, email, password_hash from accounts where email = $1", [email]); + if (!account || !(await verifyPassword(password, account.password_hash))) { return res.status(401).json({ error: "Email oder Passwort falsch." }); } - account.is_admin = await ensureAdminFlag(account); - res.json({ token: signToken(account), account: { id: account.id, email: account.email, is_admin: account.is_admin } }); + res.json({ token: signToken(account), account: { id: account.id, email: account.email } }); });