feat: production deployment configuration for dk0.dev

- Fixed authentication system (removed HTTP Basic Auth popup)
- Added session-based authentication with proper logout
- Updated rate limiting (20 req/s for login, 5 req/m for admin)
- Created production deployment scripts and configs
- Updated nginx configuration for dk0.dev domain
- Added comprehensive production deployment guide
- Fixed logout button functionality
- Optimized for production with proper resource limits
This commit is contained in:
2025-10-19 21:48:26 +02:00
parent 138b473418
commit c7bc0ecb1d
16 changed files with 931 additions and 285 deletions

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma';
import { analyticsCache } from '@/lib/redis';
import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
@@ -24,7 +24,7 @@ export async function GET(request: NextRequest) {
// The middleware has already verified the admin session for /manage routes
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
const authError = requireAdminAuth(request);
const authError = requireSessionAuth(request);
if (authError) {
return authError;
}

View File

@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAdminAuth } from '@/lib/auth';
import { requireSessionAuth } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
// Check admin authentication - for admin dashboard requests, we trust the session
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
const authError = requireAdminAuth(request);
const authError = requireSessionAuth(request);
if (authError) {
return authError;
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { analyticsCache } from '@/lib/redis';
import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
@@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
// Check admin authentication
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
const authError = requireAdminAuth(request);
const authError = requireSessionAuth(request);
if (authError) {
return authError;
}

View File

@@ -5,14 +5,14 @@ export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 5, 60000)) { // 5 login attempts per minute
if (!checkRateLimit(ip, 20, 60000)) { // 20 login attempts per minute
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
...getRateLimitHeaders(ip, 20, 60000)
}
}
);

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
// Simple logout - just return success
// The client will handle clearing the session storage
return new NextResponse(
JSON.stringify({ success: true, message: 'Logged out successfully' }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
}
);
} catch (error) {
return new NextResponse(
JSON.stringify({ error: 'Logout failed' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { apiCache } from '@/lib/cache';
import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
@@ -20,10 +20,10 @@ export async function GET(request: NextRequest) {
);
}
// Check admin authentication for admin endpoints
// Check session authentication for admin endpoints
const url = new URL(request.url);
if (url.pathname.includes('/manage') || request.headers.get('x-admin-request') === 'true') {
const authError = requireAdminAuth(request);
const authError = requireSessionAuth(request);
if (authError) {
return authError;
}

View File

@@ -2,23 +2,13 @@
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Lock,
Eye,
EyeOff,
Shield,
AlertTriangle,
XCircle,
Loader2
} from 'lucide-react';
import { Lock, Loader2 } from 'lucide-react';
import ModernAdminDashboard from '@/components/ModernAdminDashboard';
// Security constants
const MAX_ATTEMPTS = 3;
// Constants
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
const RATE_LIMIT_DELAY = 1000; // 1 second base delay
// Rate limiting with exponential backoff
const getRateLimitDelay = (attempts: number): number => {
return RATE_LIMIT_DELAY * Math.pow(2, attempts);
};
@@ -93,63 +83,56 @@ const AdminPage = () => {
// Check session validity via API
const checkSession = useCallback(async () => {
const authStatus = sessionStorage.getItem('admin_authenticated');
const sessionToken = sessionStorage.getItem('admin_session_token');
const csrfToken = authState.csrfToken;
// If no session data, show login immediately
if (!authStatus || !sessionToken || !csrfToken) {
setAuthState(prev => ({
...prev,
isAuthenticated: false,
isLoading: false,
showLogin: true
}));
return;
}
try {
const sessionToken = sessionStorage.getItem('admin_session_token');
if (!sessionToken) {
setAuthState(prev => ({
...prev,
isAuthenticated: false,
showLogin: true,
isLoading: false
}));
return;
}
const response = await fetch('/api/auth/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
'X-CSRF-Token': authState.csrfToken
},
body: JSON.stringify({
sessionToken,
csrfToken
body: JSON.stringify({
sessionToken,
csrfToken: authState.csrfToken
})
});
if (response.ok) {
setAuthState(prev => ({
...prev,
isAuthenticated: true,
isLoading: false,
showLogin: false
const data = await response.json();
if (response.ok && data.valid) {
setAuthState(prev => ({
...prev,
isAuthenticated: true,
showLogin: false,
isLoading: false
}));
return;
sessionStorage.setItem('admin_authenticated', 'true');
} else {
// Clear invalid session
sessionStorage.removeItem('admin_authenticated');
sessionStorage.removeItem('admin_session_token');
setAuthState(prev => ({
...prev,
isAuthenticated: false,
isLoading: false,
showLogin: true
setAuthState(prev => ({
...prev,
isAuthenticated: false,
showLogin: true,
isLoading: false
}));
}
} catch (error) {
console.error('Session validation error:', error);
// Clear session on error
sessionStorage.removeItem('admin_authenticated');
sessionStorage.removeItem('admin_session_token');
setAuthState(prev => ({
...prev,
isAuthenticated: false,
isLoading: false,
showLogin: true
} catch {
setAuthState(prev => ({
...prev,
isAuthenticated: false,
showLogin: true,
isLoading: false
}));
}
}, [authState.csrfToken]);
@@ -172,7 +155,7 @@ const AdminPage = () => {
if (authState.csrfToken && !authState.isLocked) {
checkSession();
}
}, [authState.csrfToken, authState.isLocked]);
}, [authState.csrfToken, authState.isLocked, checkSession]);
// Handle logout
const handleLogout = useCallback(() => {
@@ -215,95 +198,55 @@ const AdminPage = () => {
const data = await response.json();
if (response.ok && data.success) {
// Store session
sessionStorage.setItem('admin_authenticated', 'true');
sessionStorage.setItem('admin_session_token', data.sessionToken);
// Clear lockout data
localStorage.removeItem('admin_lockout');
// Update state
setAuthState(prev => ({
...prev,
isAuthenticated: true,
showLogin: false,
isLoading: false,
password: '',
error: '',
attempts: 0,
error: ''
isLoading: false
}));
localStorage.removeItem('admin_lockout');
} else {
// Failed login
const newAttempts = authState.attempts + 1;
const newLastAttempt = Date.now();
if (newAttempts >= MAX_ATTEMPTS) {
// Lock user out
setAuthState(prev => ({
...prev,
error: data.error || 'Login failed',
attempts: newAttempts,
isLoading: false
}));
if (newAttempts >= 5) {
localStorage.setItem('admin_lockout', JSON.stringify({
timestamp: newLastAttempt,
timestamp: Date.now(),
attempts: newAttempts
}));
setAuthState(prev => ({
...prev,
isLocked: true,
attempts: newAttempts,
lastAttempt: newLastAttempt,
isLoading: false,
error: `Too many failed attempts. Access locked for ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes.`
}));
} else {
setAuthState(prev => ({
...prev,
attempts: newAttempts,
lastAttempt: newLastAttempt,
isLoading: false,
error: data.error || `Wrong password. ${MAX_ATTEMPTS - newAttempts} attempts remaining.`,
password: ''
error: 'Too many failed attempts. Please try again in 15 minutes.'
}));
}
}
} catch {
setAuthState(prev => ({
...prev,
isLoading: false,
error: 'An error occurred. Please try again.'
error: 'Network error. Please try again.',
isLoading: false
}));
}
};
// Get remaining lockout time
const getRemainingTime = () => {
const lockoutData = localStorage.getItem('admin_lockout');
if (lockoutData) {
try {
const { timestamp } = JSON.parse(lockoutData);
const remaining = Math.ceil((LOCKOUT_DURATION - (Date.now() - timestamp)) / 1000 / 60);
return Math.max(0, remaining);
} catch {
return 0;
}
}
return 0;
};
// Loading state
if (authState.isLoading && !authState.showLogin) {
if (authState.isLoading) {
return (
<div className="min-h-screen">
<div className="fixed inset-0 animated-bg"></div>
<div className="relative z-10 min-h-screen flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center admin-glass-card p-8 rounded-2xl"
>
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
<Loader2 className="w-8 h-8 text-white animate-spin" />
</div>
<p className="text-white text-xl font-semibold">Verifying Access...</p>
<p className="text-white/60 text-sm mt-2">Please wait while we authenticate your session</p>
</motion.div>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-blue-500" />
<p className="text-white">Loading...</p>
</div>
</div>
);
@@ -312,44 +255,20 @@ const AdminPage = () => {
// Lockout state
if (authState.isLocked) {
return (
<div className="min-h-screen">
<div className="fixed inset-0 animated-bg"></div>
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="admin-glass-card border-red-500/40 p-8 lg:p-12 rounded-2xl max-w-md w-full text-center shadow-2xl"
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<Lock className="w-16 h-16 mx-auto mb-4 text-red-500" />
<h2 className="text-2xl font-bold text-white mb-2">Account Locked</h2>
<p className="text-white/60">Too many failed attempts. Please try again in 15 minutes.</p>
<button
onClick={() => {
localStorage.removeItem('admin_lockout');
window.location.reload();
}}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
<div className="mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
<Shield className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-3">Access Locked</h1>
<p className="text-white/80 text-lg">
Too many failed authentication attempts
</p>
</div>
<div className="admin-glass-light border border-red-500/40 rounded-xl p-6 mb-8">
<AlertTriangle className="w-8 h-8 text-red-400 mx-auto mb-4" />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-white/60 mb-1">Attempts</p>
<p className="text-red-300 font-bold text-lg">{authState.attempts}/{MAX_ATTEMPTS}</p>
</div>
<div>
<p className="text-white/60 mb-1">Time Left</p>
<p className="text-orange-300 font-bold text-lg">{getRemainingTime()}m</p>
</div>
</div>
</div>
<div className="admin-glass-light border border-blue-500/30 rounded-xl p-4">
<p className="text-white/70 text-sm">
Access will be automatically restored in {Math.ceil(LOCKOUT_DURATION / 60000)} minutes
</p>
</div>
</motion.div>
Try Again
</button>
</div>
</div>
);
@@ -358,100 +277,43 @@ const AdminPage = () => {
// Login form
if (authState.showLogin || !authState.isAuthenticated) {
return (
<div className="min-h-screen">
{/* Animated Background - same as admin dashboard */}
<div className="fixed inset-0 animated-bg"></div>
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="admin-glass-card p-8 lg:p-12 rounded-2xl max-w-md w-full shadow-2xl"
>
<div className="min-h-screen flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-md p-8"
>
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
<Shield className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-3">Admin Panel</h1>
<p className="text-white/80 text-lg">Secure access to dashboard</p>
<div className="flex items-center justify-center space-x-2 mt-4">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-white/60 text-sm font-medium">System Online</span>
<Lock className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-white mb-2">Admin Access</h1>
<p className="text-white/60">Enter your password to continue</p>
</div>
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label htmlFor="password" className="block text-sm font-medium text-white/80 mb-3">
Admin Password
</label>
<div className="relative">
<input
type={authState.showPassword ? 'text' : 'password'}
id="password"
value={authState.password}
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
className="w-full px-4 py-4 admin-glass-light border border-white/30 rounded-xl text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500/50 transition-all text-lg pr-12"
placeholder="Enter admin password"
required
placeholder="Enter password"
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={authState.isLoading}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))}
className="absolute right-4 top-1/2 transform -translate-y-1/2 text-white/60 hover:text-white transition-colors p-1"
disabled={authState.isLoading}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-white/50 hover:text-white"
>
{authState.showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
{authState.showPassword ? '👁️' : '👁️‍🗨️'}
</button>
</div>
</div>
<AnimatePresence>
{authState.error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="admin-glass-light border border-red-500/40 rounded-xl p-4 flex items-center space-x-3"
>
<XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
<p className="text-red-300 text-sm font-medium">{authState.error}</p>
</motion.div>
<p className="mt-2 text-red-400 text-sm">{authState.error}</p>
)}
</AnimatePresence>
{/* Security info */}
<div className="admin-glass-light border border-blue-500/30 rounded-xl p-6">
<div className="flex items-center space-x-3 mb-4">
<Shield className="w-5 h-5 text-blue-400" />
<h3 className="text-blue-300 font-semibold">Security Information</h3>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-white/60">Max Attempts:</span>
<span className="text-white font-medium">{MAX_ATTEMPTS}</span>
</div>
<div className="flex justify-between">
<span className="text-white/60">Lockout:</span>
<span className="text-white font-medium">{Math.ceil(LOCKOUT_DURATION / 60000)}m</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-white/60">Session:</span>
<span className="text-white font-medium">2h</span>
</div>
<div className="flex justify-between">
<span className="text-white/60">Attempts:</span>
<span className={`font-medium ${authState.attempts > 0 ? 'text-orange-400' : 'text-green-400'}`}>
{authState.attempts}/{MAX_ATTEMPTS}
</span>
</div>
</div>
</div>
</div>
<button
@@ -472,19 +334,8 @@ const AdminPage = () => {
)}
</button>
</form>
{/* Debug: Clear Session Button */}
<div className="mt-6 pt-6 border-t border-white/20">
<button
type="button"
onClick={handleLogout}
className="w-full text-white/60 hover:text-white/80 text-sm py-2 px-4 rounded-lg border border-white/20 hover:border-white/40 transition-all"
>
Clear Session & Reload
</button>
</div>
</motion.div>
</div>
</div>
</motion.div>
</div>
);
}