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
This commit is contained in:
Z User committed 2026-10-28 22:38:39 +00:00
1 parent e7185103f5
commit 17ceefe32a
8 files changed
+277 -100

No files matched your search

-95
View File
@@ -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>
# 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 <secret>
# GRAPHIFY_URL=http://localhost:8080
# GRAPHIFY_API_KEY=your-graphify-api-key
+1
View File
@@ -59,3 +59,4 @@ aos-verify*.sh
.dev.pid .dev.pid
.zscripts/dev.pid .zscripts/dev.pid
db/backups/ db/backups/
.env
+6 -4
View File
@@ -206,18 +206,20 @@ export async function POST(req: Request) {
const existingTokens = await db.apiToken.count() const existingTokens = await db.apiToken.count()
if (existingTokens === 0) { 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 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' // Use MCP_API_TOKEN env var if set, otherwise generate a random one
const hash = crypto.createHash('sha256').update(token).digest('hex') const tokenValue = process.env.MCP_API_TOKEN || ('aos_' + crypto.randomUUID())
const hash = crypto.createHash('sha256').update(tokenValue).digest('hex')
await db.apiToken.create({ await db.apiToken.create({
data: { data: {
name: 'Claude Code Admin (seeded)', name: 'MCP Admin Token (seeded)',
tokenHash: hash, tokenHash: hash,
tokenPrefix: token.slice(0, 12) + '…', tokenPrefix: tokenValue.slice(0, 12) + '…',
scopes: adminScopes.join(','), scopes: adminScopes.join(','),
expiresAt: null, expiresAt: null,
}, },
}) })
stats.apiTokens = 1 stats.apiTokens = 1
stats.mcpToken = tokenValue === process.env.MCP_API_TOKEN ? '(from env)' : tokenValue
} else { } else {
stats.apiTokens = 0 stats.apiTokens = 0
} }
+64
View File
@@ -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 })
}
}
+10
View File
@@ -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
}
+84
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-2">
<div className="mx-auto flex size-14 items-center justify-center rounded-xl bg-foreground text-background">
<Lock className="size-7" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">AOS Login</h1>
<p className="text-sm text-muted-foreground">
Agency Operating System — dfmpanel.eu
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Admin-Passwort eingeben"
autoFocus
autoComplete="current-password"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<Button type="submit" className="w-full gap-2" disabled={loading || !password}>
{loading && <Loader2 className="size-4 animate-spin" />}
Anmelden
</Button>
</form>
<p className="text-center text-xs text-muted-foreground">
Geschützter Bereich — nur für autorisierte Benutzer.
</p>
</div>
</div>
)
}
+10 -1
View File
@@ -110,7 +110,16 @@ export function TopBar() {
<DropdownMenuItem>Profil</DropdownMenuItem> <DropdownMenuItem>Profil</DropdownMenuItem>
<DropdownMenuItem>Sitzungen</DropdownMenuItem> <DropdownMenuItem>Sitzungen</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-muted-foreground">Abmelden</DropdownMenuItem> <DropdownMenuItem
className="text-muted-foreground"
onSelect={async (e) => {
e.preventDefault()
await fetch('/api/auth/logout', { method: 'POST' })
window.location.href = '/login'
}}
>
Abmelden
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
+102
View File
@@ -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<boolean> {
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).*)',
],
}