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

+6 -4
View File
@@ -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
}
+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>
)
}