From c067a4230265a9de6487b135bd342106df24d4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Sat, 28 Feb 2026 15:56:33 +0000 Subject: [PATCH] =?UTF-8?q?V1.1.0:=20Auth=20dazugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/login/actions.ts | 33 ++++++++++++ app/login/page.tsx | 79 ++++++++++++++++++++++++++++ app/page.tsx | 1 + components/LogoutButton.tsx | 23 ++++++++ lib/auth.ts | 50 ++++++++++++++++++ lib/session.ts | 102 ++++++++++++++++++++++++++++++++++++ middleware.ts | 74 ++++++++++++++++++++++++++ package-lock.json | 14 ++++- package.json | 3 +- 9 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 app/login/actions.ts create mode 100644 app/login/page.tsx create mode 100644 components/LogoutButton.tsx create mode 100644 lib/auth.ts create mode 100644 lib/session.ts create mode 100644 middleware.ts 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 dbea858..59b271f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,7 @@ import WerteForm from '@/components/WerteForm'; import WerteList from '@/components/WerteList'; import { WerteEntry } from '@/types/werte'; import packageJson from '@/package.json'; +import LogoutButton from '@/components/LogoutButton'; export default function Home() { const [entries, setEntries] = useState([]); 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/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 114c2a9..668fde5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "werte_next", - "version": "0.1.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "werte_next", - "version": "0.1.1", + "version": "1.0.0", "dependencies": { + "jose": "^6.1.3", "mysql2": "^3.17.4", "next": "16.1.6", "react": "19.2.3", @@ -4484,6 +4485,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 85abfe6..795290b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "werte_next", - "version": "1.0.0", + "version": "1.1.0", "private": true, "scripts": { "dev": "next dev", @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "jose": "^6.1.3", "mysql2": "^3.17.4", "next": "16.1.6", "react": "19.2.3",