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:
1 parent
e7185103f5
commit
17ceefe32a
8 files changed
+277
-100
No files matched your search
@@ -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
|
|
||||||
@@ -59,3 +59,4 @@ aos-verify*.sh
|
|||||||
.dev.pid
|
.dev.pid
|
||||||
.zscripts/dev.pid
|
.zscripts/dev.pid
|
||||||
db/backups/
|
db/backups/
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
Reference in new issue
Block a user