diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..62613ae --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Next.js Debug", + "runtimeExecutable": "node", + "args": [ + "--inspect-brk", + "${workspaceFolder}/node_modules/.bin/next", + "dev" + ], + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/AUTH_README.md b/AUTH_README.md new file mode 100644 index 0000000..d3e2a07 --- /dev/null +++ b/AUTH_README.md @@ -0,0 +1,174 @@ +# Wiederverwendbare Authentifizierung + +Diese Authentifizierungslösung kann einfach in andere Next.js Projekte übernommen werden. + +## Komponenten + +### 1. Core Libraries (wiederverwendbar) +- `/lib/auth.ts` - Authentifizierungslogik (Benutzerverwaltung über .env) +- `/lib/session.ts` - JWT-basiertes Session-Management +- `/middleware.ts` - Route-Schutz Middleware + +### 2. UI Komponenten (wiederverwendbar) +- `/app/login/page.tsx` - Login-Seite +- `/app/login/actions.ts` - Server Actions für Login/Logout +- `/components/LogoutButton.tsx` - Logout-Button Komponente + +## Installation in neuen Projekten + +### 1. Dependencies installieren +```bash +npm install jose bcryptjs +npm install --save-dev @types/bcryptjs +``` + +### 2. Dateien kopieren +Kopiere folgende Dateien in dein neues Projekt: +- `lib/auth.ts` +- `lib/session.ts` +- `middleware.ts` +- `app/login/` (gesamter Ordner) +- `scripts/generate-password.js` (Passwort-Hash Generator) +- `components/LogoutButton.tsx` (optional) + +### 3. Passwort-Hashes generieren +Verwende das mitgelieferte Script, um sichere Passwort-Hashes zu erstellen: + +```bash +# Interactive Mode +node scripts/generate-password.js + +# Mit Passwort als Argument +node scripts/generate-password.js meinPasswort123 +``` + +Das Script gibt einen bcrypt-Hash aus, den du in der `.env` verwenden kannst. + +### 4. Umgebungsvariablen einrichten +Füge zu deiner `.env` hinzu: + +```env +# Authentifizierung +# Format: username:passwordHash,username2:passwordHash2 +# Verwende 'node scripts/generate-password.js' um Hashes zu generieren +AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro...,user1:$2b$10$K613Z70Hodr6xyEh10Mw2u... + +# Secret Key für JWT (unbedingt ändern in Production!) +AUTH_SECRET=your-super-secret-key-change-this +``` + +### 5. Logout-Button einbinden (optional) +```tsx +import LogoutButton from '@/components/LogoutButton'; + +// In deiner Komponente: + +``` + +## Konfiguration + +### Benutzer hinzufügen/entfernen + +1. Generiere einen Passwort-Hash: +```bash +node scripts/generate-password.js neuesPasswort +``` + +2. Editiere die `AUTH_USERS` Variable in der `.env`: +```env +AUTH_USERS=user1:$2b$10$hash1...,user2:$2b$10$hash2...,user3:$2b$10$hash3... +``` + +### Authentifizierung deaktivieren +Entferne die `AUTH_USERS` Variable oder setze sie auf einen leeren String: +```env +AUTH_USERS= +``` + +### Session-Dauer anpassen +Editiere in `lib/session.ts`: +```ts +const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 Tage +``` + +### Geschützte Routen anpassen +Editiere in `middleware.ts` die `publicPaths`: +```ts +const publicPaths = ['/login', '/public-page']; +``` + +## Sicherheitshinweise + +1. **AUTH_SECRET ändern**: Verwende in Production einen starken, zufälligen Schlüssel +2. **HTTPS verwenden**: In Production immer HTTPS aktivieren +3. **Passwort-Hashing**: Passwörter werden mit bcrypt gehashed (10 Salt Rounds) +4. **Keine Klartext-Passwörter**: Verwende immer das Script zur Hash-Generierung + +## Passwort-Hash Generator + +Das Script `scripts/generate-password.js` verwendet bcrypt mit 10 Salt Rounds, um sichere Passwort-Hashes zu erstellen. + +### Verwendung + +Interactive Mode (empfohlen für sensible Passwörter): +```bash +npm run generate-password +# oder +node scripts/generate-password.js +# Passwort wird interaktiv abgefragt +``` + +Mit Argument: +```bash +npm run generate-password -- meinPasswort +# oder +node scripts/generate-password.js meinPasswort +``` + +### Ausgabe +``` +🔐 Generiere Passwort-Hash... + +✅ Hash generiert: +──────────────────────────────────────────────────────────────────────────────── +$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW +──────────────────────────────────────────────────────────────────────────────── + +📝 Verwende diesen Hash in der .env Datei: +AUTH_USERS=username:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW +``` + +## Erweiterte Verwendung + +### Session-Informationen abrufen +```ts +import { getSession } from '@/lib/session'; + +const session = await getSession(); +if (session) { + console.log('Eingeloggt als:', session.username); +} +``` + +### Programmatisch prüfen, ob authentifiziert +```ts +import { isAuthenticated } from '@/lib/session'; + +const authenticated = await isAuthenticated(); +``` + +### In Server Components +```tsx +import { getSession } from '@/lib/session'; +import { redirect } from 'next/navigation'; + +export default async function ProtectedPage() { + const session = await getSession(); + + if (!session) { + redirect('/login'); + } + + return
Hallo {session.username}!
; +} +``` diff --git a/Dockerfile b/Dockerfile index e6ab089..661970d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs # Copy necessary files -COPY --from=builder /app/public ./public +#COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 0000000..80e8850 --- /dev/null +++ b/app/login/actions.ts @@ -0,0 +1,33 @@ +'use server'; + +import { verifyCredentials } from '@/lib/auth'; +import { createSession, deleteSession } from '@/lib/session'; +import { redirect } from 'next/navigation'; + +export async function login(prevState: any, formData: FormData) { + const username = formData.get('username') as string; + const password = formData.get('password') as string; + + console.log('Login attempt:', { username, passwordLength: password?.length }); + console.log('AUTH_USERS env:', process.env.AUTH_USERS); + + if (!username || !password) { + return { error: 'Bitte Benutzername und Passwort eingeben' }; + } + + const isValid = verifyCredentials(username, password); + + console.log('Credentials valid:', isValid); + + if (!isValid) { + return { error: 'Ungültige Anmeldedaten' }; + } + + await createSession(username); + redirect('/'); +} + +export async function logout() { + await deleteSession(); + redirect('/login'); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..efff5f8 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useActionState } from 'react'; +import { login } from './actions'; + +export default function LoginPage() { + const [state, loginAction, isPending] = useActionState(login, undefined); + + return ( +
+
+
+

+ Anmeldung +

+

+ Bitte melden Sie sich an, um fortzufahren +

+
+ +
+
+
+ + +
+ +
+ + +
+
+ + {state?.error && ( +
+ {state.error} +
+ )} + + +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index ef6fe49..d94e351 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import AusgabenForm from '@/components/AusgabenForm'; import AusgabenList from '@/components/AusgabenList'; +import LogoutButton from '@/components/LogoutButton'; import { AusgabenEntry } from '@/types/ausgaben'; import packageJson from '@/package.json'; @@ -59,7 +60,10 @@ export default function Home() { return (
-

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);