- Ausgaben - Log
+
+
Ausgaben - Log
+
+
{/* Tab Navigation */}
diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx
new file mode 100644
index 0000000..1423a9e
--- /dev/null
+++ b/components/LogoutButton.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { logout } from '@/app/login/actions';
+
+interface LogoutButtonProps {
+ className?: string;
+ children?: React.ReactNode;
+}
+
+export default function LogoutButton({ className, children }: LogoutButtonProps) {
+ const handleLogout = async () => {
+ await logout();
+ };
+
+ return (
+
+ );
+}
diff --git a/debug-auth.js b/debug-auth.js
new file mode 100644
index 0000000..c714a77
--- /dev/null
+++ b/debug-auth.js
@@ -0,0 +1,35 @@
+const bcrypt = require('bcryptjs');
+
+// Direkt aus .env kopiert
+const AUTH_USERS = 'rxf:$2b$10$VdshbfnSFZIn59QJqDRiROi.ekU83ObiQBM.R3MVaSIcGQb5eYbEq';
+
+console.log('=== AUTH DEBUG ===\n');
+console.log('AUTH_USERS:', AUTH_USERS);
+console.log('');
+
+const usersString = AUTH_USERS || '';
+const users = usersString
+ .split(',')
+ .map((userPair) => {
+ const [username, passwordHash] = userPair.trim().split(':');
+ return { username: username?.trim(), passwordHash: passwordHash?.trim() };
+ })
+ .filter((user) => user.username && user.passwordHash);
+
+console.log('Parsed users:', JSON.stringify(users, null, 2));
+console.log('');
+
+// Test credentials
+const testUser = 'rxf';
+const testPassword = 'Fluorit';
+
+const user = users.find(u => u.username === testUser);
+console.log('Found user:', user);
+console.log('');
+
+if (user) {
+ console.log('Testing password:', testPassword);
+ console.log('Against hash:', user.passwordHash);
+ const result = bcrypt.compareSync(testPassword, user.passwordHash);
+ console.log('Result:', result);
+}
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index 4a9f8e2..8da2708 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -15,3 +15,5 @@ services:
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME}
+ - AUTH_USERS=${AUTH_USERS}
+ - AUTH_SECRET=${AUTH_SECRET}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 70b1cd5..4e3ffa4 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -12,6 +12,8 @@ services:
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME}
+ - AUTH_USERS=${AUTH_USERS}
+ - AUTH_SECRET=${AUTH_SECRET}
labels:
- traefik.enable=true
- traefik.http.routers.ausgaben.entrypoints=http
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..7b1fc5b
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,50 @@
+/**
+ * Reusable authentication library
+ * Configure users via environment variables in .env:
+ * AUTH_USERS=user1:$2a$10$hash1,user2:$2a$10$hash2
+ *
+ * Use scripts/generate-password.js to generate password hashes
+ */
+
+export interface User {
+ username: string;
+ password: string;
+}
+
+/**
+ * Parse users from environment variable
+ * Format: username:password,username2:password2
+ */
+export function getUsers(): User[] {
+ const usersString = process.env.AUTH_USERS || '';
+ if (!usersString) {
+ console.warn('AUTH_USERS not configured in .env');
+ return [];
+ }
+ return usersString
+ .split(',')
+ .map((userPair) => {
+ const [username, password] = userPair.trim().split(':');
+ return { username: username?.trim(), password: password?.trim() };
+ })
+ .filter((user) => user.username && user.password);
+}
+
+/**
+ * Verify user credentials
+ */
+export function verifyCredentials(username: string, password: string): boolean {
+ const users = getUsers();
+ const user = users.find(u => u.username === username);
+ if (!user) {
+ return false;
+ }
+ return user.password === password;
+}
+
+/**
+ * Check if authentication is enabled
+ */
+export function isAuthEnabled(): boolean {
+ return !!process.env.AUTH_USERS;
+}
diff --git a/lib/session.ts b/lib/session.ts
new file mode 100644
index 0000000..d9bd8e0
--- /dev/null
+++ b/lib/session.ts
@@ -0,0 +1,102 @@
+import { cookies } from 'next/headers';
+import { SignJWT, jwtVerify } from 'jose';
+
+const SESSION_COOKIE_NAME = 'auth_session';
+const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
+
+const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
+const key = new TextEncoder().encode(secretKey);
+
+export interface SessionData {
+ username: string;
+ isAuthenticated: boolean;
+ expiresAt: number;
+}
+
+/**
+ * Encrypt session data to JWT
+ */
+async function encrypt(payload: SessionData): Promise {
+ return await new SignJWT(payload as any)
+ .setProtectedHeader({ alg: 'HS256' })
+ .setIssuedAt()
+ .setExpirationTime(new Date(payload.expiresAt))
+ .sign(key);
+}
+
+/**
+ * Decrypt JWT to session data
+ */
+async function decrypt(token: string): Promise {
+ try {
+ const { payload } = await jwtVerify(token, key, {
+ algorithms: ['HS256'],
+ });
+ return {
+ username: payload.username as string,
+ isAuthenticated: payload.isAuthenticated as boolean,
+ expiresAt: payload.expiresAt as number,
+ };
+ } catch (error) {
+ return null;
+ }
+}
+
+/**
+ * Create a new session
+ */
+export async function createSession(username: string): Promise {
+ const expiresAt = Date.now() + SESSION_DURATION;
+ const session: SessionData = {
+ username,
+ isAuthenticated: true,
+ expiresAt,
+ };
+
+ const encryptedSession = await encrypt(session);
+ const cookieStore = await cookies();
+
+ cookieStore.set(SESSION_COOKIE_NAME, encryptedSession, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ expires: expiresAt,
+ sameSite: 'lax',
+ path: '/',
+ });
+}
+
+/**
+ * Get current session
+ */
+export async function getSession(): Promise {
+ const cookieStore = await cookies();
+ const cookie = cookieStore.get(SESSION_COOKIE_NAME);
+
+ if (!cookie?.value) {
+ return null;
+ }
+
+ const session = await decrypt(cookie.value);
+
+ if (!session || session.expiresAt < Date.now()) {
+ return null;
+ }
+
+ return session;
+}
+
+/**
+ * Delete session (logout)
+ */
+export async function deleteSession(): Promise {
+ const cookieStore = await cookies();
+ cookieStore.delete(SESSION_COOKIE_NAME);
+}
+
+/**
+ * Verify if user is authenticated
+ */
+export async function isAuthenticated(): Promise {
+ const session = await getSession();
+ return session?.isAuthenticated ?? false;
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..06f2505
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,74 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import { jwtVerify } from 'jose';
+
+const SESSION_COOKIE_NAME = 'auth_session';
+
+/**
+ * Middleware to protect routes with authentication
+ * Reusable for other projects - just copy this file
+ */
+export async function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // Check if authentication is enabled
+ const authEnabled = !!process.env.AUTH_USERS;
+
+ // If auth is not enabled, allow all requests
+ if (!authEnabled) {
+ return NextResponse.next();
+ }
+
+ // Public paths that don't require authentication
+ const publicPaths = ['/login'];
+ const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
+
+ if (isPublicPath) {
+ return NextResponse.next();
+ }
+
+ // Check for session cookie
+ const cookieStore = await cookies();
+ const sessionCookie = cookieStore.get(SESSION_COOKIE_NAME);
+
+ if (!sessionCookie) {
+ return NextResponse.redirect(new URL('/login', request.url));
+ }
+
+ // Verify session token
+ try {
+ const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
+ const key = new TextEncoder().encode(secretKey);
+
+ const { payload } = await jwtVerify(sessionCookie.value, key, {
+ algorithms: ['HS256'],
+ });
+
+ // Check if session is expired
+ if (payload.expiresAt && (payload.expiresAt as number) < Date.now()) {
+ const response = NextResponse.redirect(new URL('/login', request.url));
+ response.cookies.delete(SESSION_COOKIE_NAME);
+ return response;
+ }
+
+ return NextResponse.next();
+ } catch (error) {
+ // Invalid token - redirect to login
+ const response = NextResponse.redirect(new URL('/login', request.url));
+ response.cookies.delete(SESSION_COOKIE_NAME);
+ return response;
+ }
+}
+
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ * - public folder
+ */
+ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
+ ],
+};
diff --git a/package-lock.json b/package-lock.json
index 08cffbf..6ae24b3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,13 +1,15 @@
{
"name": "ausgaben_next",
- "version": "1.0.0",
+ "version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ausgaben_next",
- "version": "1.0.0",
+ "version": "1.0.1",
"dependencies": {
+ "bcryptjs": "^3.0.3",
+ "jose": "^6.1.3",
"mysql2": "^3.17.4",
"next": "16.1.6",
"react": "19.2.3",
@@ -15,6 +17,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1525,6 +1528,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2453,6 +2463,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4483,6 +4502,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
+ "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
diff --git a/package.json b/package.json
index 8ff02e7..6898cbe 100644
--- a/package.json
+++ b/package.json
@@ -6,9 +6,12 @@
"dev": "next dev -p 3005",
"build": "next build",
"start": "next start -p 3005",
- "lint": "eslint"
+ "lint": "eslint",
+ "generate-password": "node scripts/generate-password.js"
},
"dependencies": {
+ "bcryptjs": "^3.0.3",
+ "jose": "^6.1.3",
"mysql2": "^3.17.4",
"next": "16.1.6",
"react": "19.2.3",
@@ -16,6 +19,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/scripts/generate-password.js b/scripts/generate-password.js
new file mode 100755
index 0000000..15a1c00
--- /dev/null
+++ b/scripts/generate-password.js
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+
+/**
+ * Password Hash Generator
+ *
+ * Usage:
+ * node scripts/generate-password.js [password]
+ *
+ * If no password is provided, you'll be prompted to enter one.
+ */
+
+const bcrypt = require('bcryptjs');
+const readline = require('readline');
+
+function generateHash(password) {
+ const saltRounds = 10;
+ const hash = bcrypt.hashSync(password, saltRounds);
+ return hash;
+}
+
+function promptPassword() {
+ return new Promise((resolve) => {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout
+ });
+
+ rl.question('Passwort eingeben: ', (password) => {
+ rl.close();
+ resolve(password);
+ });
+ });
+}
+
+async function main() {
+ let password = process.argv[2];
+
+ if (!password) {
+ password = await promptPassword();
+ }
+
+ if (!password) {
+ console.error('❌ Kein Passwort angegeben!');
+ process.exit(1);
+ }
+
+ console.log('\n🔐 Generiere Passwort-Hash...\n');
+
+ const hash = generateHash(password);
+
+ console.log('✅ Hash generiert:');
+ console.log('─'.repeat(80));
+ console.log(hash);
+ console.log('─'.repeat(80));
+ console.log('\n📝 Verwende diesen Hash in der .env Datei:');
+ console.log(`AUTH_USERS=username:${hash}`);
+ console.log('\n💡 Beispiel für mehrere Benutzer:');
+ console.log(`AUTH_USERS=admin:${hash},user2:$2a$10$...\n`);
+}
+
+main().catch(console.error);