From 17ceefe32a820db0115dc83247f966997704b994 Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 22:38:39 +0000 Subject: [PATCH] feat: auth system (middleware + login) + remove .env from git + fix hardcoded token CRITICAL SECURITY FIXES: - src/middleware.ts: protects all routes except public ones (health, license check, seed-prod) Redirects unauthenticated users to /login, returns 401 for API routes - src/app/login/page.tsx: login page with password input - src/app/api/auth/login: HMAC-signed session cookie (7-day expiry) - src/app/api/auth/logout: clears session cookie - Top bar: Abmelden (logout) button now functional - .env removed from git tracking (secrets no longer in repo) - seed-prod: hardcoded admin token replaced with env MCP_API_TOKEN or random UUID - ADMIN_PASSWORD env var required for login (set in Dokploy env) Public routes (no auth needed): /api/health, /api/aos/seed-prod, /api/aos/licenses/check, /api/aos/licenses/validate /api/v1/* (token-auth'd), /api/mcp (token-auth'd), /login --- .env | 95 --------------------------- .gitignore | 1 + src/app/api/aos/seed-prod/route.ts | 10 +-- src/app/api/auth/login/route.ts | 64 ++++++++++++++++++ src/app/api/auth/logout/route.ts | 10 +++ src/app/login/page.tsx | 84 ++++++++++++++++++++++++ src/components/aos/top-bar.tsx | 11 +++- src/middleware.ts | 102 +++++++++++++++++++++++++++++ 8 files changed, 277 insertions(+), 100 deletions(-) delete mode 100755 .env create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/middleware.ts diff --git a/.env b/.env deleted file mode 100755 index 774cc10..0000000 --- a/.env +++ /dev/null @@ -1,95 +0,0 @@ -# AOS — Agency Operating System Environment (dev) - -# --- Database --- -DATABASE_URL=file:/home/z/my-project/db/custom.db - -# --- Encryption (AES-256-GCM for secrets at rest) --- -ENCRYPTION_KEY=UrEE757ABMzqcME/AQfef9XDPSaBlgIhVDOy8BIpbSY= - -# --- Auth --- -AUTH_SECRET=zB7wxxn7xDnClPUlwoKG4VwHDaIqvtAz3DW3Bn43GlM= - -# --- Leads Finder Providers --- -TAVILY_API_KEY=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 -FIRECRAWL_API_KEY=fc-2ddabe84274a44b39da31a49d1624700 -SERPAPI_API_KEY=5e1d045575cc29b705c53e8303b6ada506e8a3ef382fca78a2964c7e25751ba1 -APIFY_API_TOKEN=apify_api_YUsr1w7L0qFpvzwDirjv1oWHToT2xu3lhJt7 - -# --- App --- -NODE_ENV=development -PORT=3000 - -# ── Stripe ── -STRIPE_PUBLISHABLE_KEY=pk_test_51TWfTxDGcQIOgPxeHVZh5ilWEc61bK7MBuBez4fGV5XrjRGwRXsXJp5rm1eAsWHGzQ20NBXRG7Nmp0pJRJfUflImA003OoDaSQt -STRIPE_SECRET_KEY=sk_test_51TWfTxDGcQIOgPxej8AvcfLMt6a7IEgZk3L34WLnOCNw2AAAyqORQtgJLjEaj3Ygm014ZAo3oByvDSdJVFwYLHmk0048mdLZH4 -RESTRICTED_KEY=rk_test_51TWfTxDGcQIOgPxeGPSfA6ske0A6xPde23jfOJKbUfFOrEWl4bTIHv4MSOBwlQpravXk4mxKAjKdmkGb3asZqOVJ0048mdLZH4 - -# ── License Server (B2B SaaS) ── -LICENSE_PRICE_STARTER_CENTS=2900 -LICENSE_PRICE_PRO_CENTS=5000 -LICENSE_PRICE_ENTERPRISE_CENTS=15000 -LICENSE_TRIAL_DAYS=14 -LICENSE_NOTICE_PERIOD_DAYS=90 -LICENSE_YEARLY_DISCOUNT_PCT=10 -LICENSE_OFFLINE_GRACE_HOURS=72 - -# ── Agency / Reseller ── -AGENCY_DISCOUNT_MIN_PCT=0 -AGENCY_DISCOUNT_MAX_PCT=20 -AGENCY_DISCOUNT_DEFAULT_PCT=10 - -# ── Platform Domain ── -# The domain AOS runs on. Tenant instances get subdomains of this domain. -PLATFORM_DOMAIN=dfmpanel.eu -# Public URL of the AOS instance (used by license-server SDK + email links) -AOS_PUBLIC_URL=https://dfmpanel.eu - -# ── Dokploy (tenant instance provisioning) ── -# Dokploy server URL + API key for managing tenant instances. -DOKPLOY_URL=https://cp.project-url.eu -DOKPLOY_API_KEY=CEIqJlWVtmSNgffNWywTJmEeuvUJBndinBzudretwiuzVWPxuvUNmoQaHUwRwUBy -# The Dokploy server ID to deploy to (default "default" if not set) -# DOKPLOY_SERVER_ID=default -# The Dokploy project to group tenant applications under -# DOKPLOY_PROJECT_ID=default -# The IP of the Dokploy server (used for Hetzner DNS A records) -# DOKPLOY_SERVER_IP=123.456.789.012 - -# ── Hetzner Cloud DNS ── -# DNS is managed via the Hetzner Cloud API (api.hetzner.cloud/v1/zones). -# The legacy dns.hetzner.com API was shut down in May 2026. -HETZNER_DNS_TOKEN=JhZMOBTMeWvXz91KVPxRh0U4UMFqkSNCMHJKiQS9kUjHIwCkWCc8ViZTm64MyaOp -# Zone ID of dfmpanel.eu (found via: GET https://api.hetzner.cloud/v1/zones) -HETZNER_ZONE_ID=1669672 -# If true, a single *.dfmpanel.eu wildcard A record handles all subdomains. -# If false, a per-instance A record is created/deleted with each instance. -USE_WILDCARD_DNS=false - -# ── Dokploy Server ── -# IP of the Dokploy server (A record target for instance subdomains). -# Found from the existing @ and www A records of dfmpanel.eu. -DOKPLOY_SERVER_IP=92.206.22.59 - -# ── Cron Job ── -# Shared secret for the cleanup cron job (POST /api/aos/instances/cleanup) -# The cron job (Dokploy cron or GitHub Action) must send this as: -# Authorization: Bearer -# CRON_SECRET=your-cron-secret - -# ── Instance Defaults ── -# Default Git repo + branch for new tenant instances -INSTANCE_GIT_REPO=https://github.com/cetinmustafa83/restov1 -INSTANCE_GIT_BRANCH=main -# Demo instance duration in days -INSTANCE_DEMO_DAYS=14 -# Grace period after demo expiry or subscription cancellation (days) -INSTANCE_GRACE_DAYS=7 - -# ── Graphify (tenant codebase knowledge graph) ── -# Graphify MCP HTTP service (Docker container). When configured, new tenant -# instances trigger a background graph build, and the support ticket AI -# queries the codebase before generating a summary. -# Run: docker run -p 8080:8080 -v ./data/graphify:/data graphify serve -# --transport http --host 0.0.0.0 --api-key -# GRAPHIFY_URL=http://localhost:8080 -# GRAPHIFY_API_KEY=your-graphify-api-key diff --git a/.gitignore b/.gitignore index f2c2acf..287beb7 100755 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ aos-verify*.sh .dev.pid .zscripts/dev.pid db/backups/ +.env diff --git a/src/app/api/aos/seed-prod/route.ts b/src/app/api/aos/seed-prod/route.ts index 1f9efeb..9278ca0 100644 --- a/src/app/api/aos/seed-prod/route.ts +++ b/src/app/api/aos/seed-prod/route.ts @@ -206,18 +206,20 @@ export async function POST(req: Request) { const existingTokens = await db.apiToken.count() if (existingTokens === 0) { const adminScopes = ['mcp:read', 'mcp:write', 'crm.company:read', 'crm.deal:read', 'finance.invoice:read', 'leads:read', 'leads:write', 'support:read', 'support:write', 'licenses.license:read', 'licenses.license:write'] - const token = 'aos_admin_seed_token_for_claude_code_2026' - const hash = crypto.createHash('sha256').update(token).digest('hex') + // Use MCP_API_TOKEN env var if set, otherwise generate a random one + const tokenValue = process.env.MCP_API_TOKEN || ('aos_' + crypto.randomUUID()) + const hash = crypto.createHash('sha256').update(tokenValue).digest('hex') await db.apiToken.create({ data: { - name: 'Claude Code Admin (seeded)', + name: 'MCP Admin Token (seeded)', tokenHash: hash, - tokenPrefix: token.slice(0, 12) + '…', + tokenPrefix: tokenValue.slice(0, 12) + '…', scopes: adminScopes.join(','), expiresAt: null, }, }) stats.apiTokens = 1 + stats.mcpToken = tokenValue === process.env.MCP_API_TOKEN ? '(from env)' : tokenValue } else { stats.apiTokens = 0 } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..a135cae --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +export const dynamic = 'force-dynamic' + +const schema = z.object({ + password: z.string().min(1), + redirect: z.string().optional(), +}) + +// POST /api/auth/login +// Simple password-based auth. Password is set via ADMIN_PASSWORD env var. +// Returns a session cookie (HMAC-signed, 7-day expiry). +export async function POST(req: Request) { + try { + const parsed = schema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'invalid request' }, { status: 400 }) + } + const { password, redirect } = parsed.data + + const adminPassword = process.env.ADMIN_PASSWORD + if (!adminPassword) { + return NextResponse.json( + { error: 'ADMIN_PASSWORD env not configured. Set it in Dokploy environment variables.' }, + { status: 503 } + ) + } + + if (password !== adminPassword) { + return NextResponse.json({ error: 'Falsches Passwort' }, { status: 401 }) + } + + // Create session token: base64(payload).hmac + const crypto = await import('crypto') + const payload = { + sub: 'admin', + iat: Date.now(), + exp: Date.now() + 7 * 24 * 3600 * 1000, // 7 days + } + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url') + const key = process.env.AUTH_SECRET || adminPassword + const signature = crypto + .createHmac('sha256', key) + .update(payloadB64) + .digest('base64url') + const token = `${payloadB64}.${signature}` + + const res = NextResponse.json({ + ok: true, + redirect: redirect || '/', + }) + res.cookies.set('aos-session', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 7 * 24 * 3600, // 7 days + }) + return res + } catch { + return NextResponse.json({ error: 'login failed' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..fde94b2 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' + +// POST /api/auth/logout +export async function POST() { + const res = NextResponse.json({ ok: true }) + res.cookies.delete('aos-session') + return res +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..bfd9899 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,84 @@ +'use client' + +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Loader2, Lock } from 'lucide-react' + +export default function LoginPage() { + const router = useRouter() + const searchParams = useSearchParams() + const redirect = searchParams.get('redirect') || '/' + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!password) return + setLoading(true) + setError('') + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password, redirect }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error || 'Login fehlgeschlagen') + return + } + router.push(data.redirect || '/') + router.refresh() + } catch { + setError('Netzwerkfehler') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+ +
+

AOS Login

+

+ Agency Operating System — dfmpanel.eu +

+
+ +
+
+ + setPassword(e.target.value)} + placeholder="Admin-Passwort eingeben" + autoFocus + autoComplete="current-password" + /> +
+ {error && ( +

{error}

+ )} + +
+ +

+ Geschützter Bereich — nur für autorisierte Benutzer. +

+
+
+ ) +} diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 69fb15c..25b2e70 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -110,7 +110,16 @@ export function TopBar() { Profil Sitzungen - Abmelden + { + e.preventDefault() + await fetch('/api/auth/logout', { method: 'POST' }) + window.location.href = '/login' + }} + > + Abmelden + diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..b1bcd51 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from 'next/server' + +// Middleware: protects all routes except public ones. +// Uses a simple session cookie (HMAC-signed) for auth. +// Password is set via ADMIN_PASSWORD env var. + +const PUBLIC_PATHS = [ + '/api/health', + '/api/aos/seed-prod', + '/api/aos/licenses/check', + '/api/aos/licenses/validate', + '/login', + '/_next', + '/favicon.ico', + '/manifest.json', + '/icons/', + '/apple-touch-icon', +] + +const PUBLIC_API_PREFIXES = [ + '/api/health', + '/api/aos/seed-prod', + '/api/aos/licenses/check', + '/api/aos/licenses/validate', + '/api/v1/', // public REST API (token-auth'd separately) + '/api/mcp', // MCP (token-auth'd) +] + +function isPublic(pathname: string): boolean { + // Exact matches + if (PUBLIC_PATHS.includes(pathname)) return true + // Prefix matches + for (const prefix of PUBLIC_API_PREFIXES) { + if (pathname.startsWith(prefix)) return true + } + // Static assets + if (pathname.startsWith('/_next/') || pathname.startsWith('/icons/')) return true + return false +} + +async function verifySession(token: string | undefined): Promise { + if (!token) return false + const adminPassword = process.env.ADMIN_PASSWORD + if (!adminPassword) return false + try { + // Session token format: base64(payload).hmac + const [payloadB64, signature] = token.split('.') + if (!payloadB64 || !signature) return false + const payload = JSON.parse(Buffer.from(payloadB64, 'base64').toString()) + if (payload.exp && Date.now() > payload.exp) return false + // Verify HMAC + const crypto = await import('crypto') + const key = process.env.AUTH_SECRET || adminPassword + const expectedSig = crypto + .createHmac('sha256', key) + .update(payloadB64) + .digest('base64url') + return signature === expectedSig + } catch { + return false + } +} + +export async function middleware(req: NextRequest) { + const { pathname } = req.nextUrl + + // Allow public paths + if (isPublic(pathname)) { + return NextResponse.next() + } + + // Check session cookie + const sessionToken = req.cookies.get('aos-session')?.value + const isAuthenticated = await verifySession(sessionToken) + + if (!isAuthenticated) { + // For API routes, return 401 + if (pathname.startsWith('/api/')) { + return NextResponse.json( + { error: 'unauthorized', message: 'Authentication required' }, + { status: 401 } + ) + } + // For pages, redirect to login + const loginUrl = new URL('/login', req.url) + loginUrl.searchParams.set('redirect', pathname) + return NextResponse.redirect(loginUrl) + } + + return NextResponse.next() +} + +export const config = { + matcher: [ + /* + * Match all request paths except: + * - _next/static, _next/image (static files) + * - favicon.ico, icons, manifest (public assets) + */ + '/((?!_next/static|_next/image|favicon.ico|icons|manifest.json|apple-touch-icon).*)', + ], +}