weiter die Sache mit Auth
This commit is contained in:
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
174
AUTH_README.md
Normal file
174
AUTH_README.md
Normal file
@@ -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:
|
||||||
|
<LogoutButton />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 <div>Hallo {session.username}!</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -37,7 +37,7 @@ RUN addgroup --system --gid 1001 nodejs
|
|||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy necessary files
|
# 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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
|||||||
33
app/login/actions.ts
Normal file
33
app/login/actions.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
79
app/login/page.tsx
Normal file
79
app/login/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 px-4">
|
||||||
|
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Anmeldung
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Bitte melden Sie sich an, um fortzufahren
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={loginAction} className="mt-8 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||||
|
placeholder="Benutzername"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||||
|
placeholder="Passwort"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state?.error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg text-sm">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import AusgabenForm from '@/components/AusgabenForm';
|
import AusgabenForm from '@/components/AusgabenForm';
|
||||||
import AusgabenList from '@/components/AusgabenList';
|
import AusgabenList from '@/components/AusgabenList';
|
||||||
|
import LogoutButton from '@/components/LogoutButton';
|
||||||
import { AusgabenEntry } from '@/types/ausgaben';
|
import { AusgabenEntry } from '@/types/ausgaben';
|
||||||
import packageJson from '@/package.json';
|
import packageJson from '@/package.json';
|
||||||
|
|
||||||
@@ -59,7 +60,10 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white py-4 px-4">
|
<div className="min-h-screen bg-white py-4 px-4">
|
||||||
<main className="max-w-7xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
|
<main className="max-w-7xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
|
||||||
<h1 className="text-3xl font-bold text-center mb-6">Ausgaben - Log</h1>
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Ausgaben - Log</h1>
|
||||||
|
<LogoutButton className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors shadow-md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
|
|||||||
23
components/LogoutButton.tsx
Normal file
23
components/LogoutButton.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={className || "px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"}
|
||||||
|
>
|
||||||
|
{children || 'Abmelden'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
debug-auth.js
Normal file
35
debug-auth.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -15,3 +15,5 @@ services:
|
|||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
- DB_PASS=${DB_PASS}
|
- DB_PASS=${DB_PASS}
|
||||||
- DB_NAME=${DB_NAME}
|
- DB_NAME=${DB_NAME}
|
||||||
|
- AUTH_USERS=${AUTH_USERS}
|
||||||
|
- AUTH_SECRET=${AUTH_SECRET}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
- DB_PASS=${DB_PASS}
|
- DB_PASS=${DB_PASS}
|
||||||
- DB_NAME=${DB_NAME}
|
- DB_NAME=${DB_NAME}
|
||||||
|
- AUTH_USERS=${AUTH_USERS}
|
||||||
|
- AUTH_SECRET=${AUTH_SECRET}
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.ausgaben.entrypoints=http
|
- traefik.http.routers.ausgaben.entrypoints=http
|
||||||
|
|||||||
50
lib/auth.ts
Normal file
50
lib/auth.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
102
lib/session.ts
Normal file
102
lib/session.ts
Normal file
@@ -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<string> {
|
||||||
|
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<SessionData | null> {
|
||||||
|
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<void> {
|
||||||
|
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<SessionData | null> {
|
||||||
|
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<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify if user is authenticated
|
||||||
|
*/
|
||||||
|
export async function isAuthenticated(): Promise<boolean> {
|
||||||
|
const session = await getSession();
|
||||||
|
return session?.isAuthenticated ?? false;
|
||||||
|
}
|
||||||
74
middleware.ts
Normal file
74
middleware.ts
Normal file
@@ -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)$).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "ausgaben_next",
|
"name": "ausgaben_next",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ausgaben_next",
|
"name": "ausgaben_next",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.1.3",
|
||||||
"mysql2": "^3.17.4",
|
"mysql2": "^3.17.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -1525,6 +1528,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2453,6 +2463,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -4483,6 +4502,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
"dev": "next dev -p 3005",
|
"dev": "next dev -p 3005",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3005",
|
"start": "next start -p 3005",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"generate-password": "node scripts/generate-password.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.1.3",
|
||||||
"mysql2": "^3.17.4",
|
"mysql2": "^3.17.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -16,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
61
scripts/generate-password.js
Executable file
61
scripts/generate-password.js
Executable file
@@ -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);
|
||||||
Reference in New Issue
Block a user