add keycloak

This commit is contained in:
Elias Bennour 2025-05-25 15:15:27 +02:00
parent ad53609099
commit d3e4882d9e
2 changed files with 165 additions and 148 deletions

View File

@ -1,20 +1,57 @@
// Datei: pages/api/auth/[...nextauth].ts
import NextAuth, { AuthOptions } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import prisma from "../../../lib/prisma" // Pfad zu deinem Prisma Client Singleton anpassen
import bcrypt from "bcryptjs"
import NextAuth, {AuthOptions} from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import KeycloakProvider, { KeycloakProfile } from "next-auth/providers/keycloak";
// Falls 'next-auth/jwt' nicht funktioniert, versuche 'next-auth/core/types'
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import prisma from "../../../lib/prisma";
import bcrypt from "bcryptjs";
// User-Typ aus deinen globalen NextAuth-Typen (next-auth.d.ts)
// wird für den Rückgabetyp des profile-Callbacks benötigt.
import type { User } from "next-auth";
// authOptions explizit als AuthOptions typisieren
export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma), // Der Adapter wird weiterhin für User/Account Management genutzt
adapter: PrismaAdapter(prisma),
session: {
strategy: "jwt", // Beibehaltung der JWT-Strategie
strategy: "jwt",
},
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID as string,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET as string,
issuer: process.env.KEYCLOAK_ISSUER as string,
profile(profile: KeycloakProfile): User | Promise<User> {
// profile: Das von Keycloak zurückgegebene Benutzerprofil.
// tokens: Enthält access_token, id_token etc. und entspricht dem TokenSet-Typ.
let userRole = 'user'; // Standardrolle
// Beispiel für Rollenextraktion (passe dies an deine Keycloak-Konfiguration an):
// Oft sind Rollen in 'realm_access.roles' oder 'resource_access.[client-id].roles'
// im dekodierten Access Token oder ID Token.
// Oder Keycloak Mappers können Rollen direkt ins Profil-Objekt legen.
if (profile.realm_access?.roles && Array.isArray(profile.realm_access.roles) && profile.realm_access.roles.includes('admin')) {
userRole = 'admin';
} else if (profile.roles && Array.isArray(profile.roles) && profile.roles.includes('admin')) { // Falls 'roles' direkt im Profil ist
userRole = 'admin';
}
return {
id: profile.sub,
name: profile.name || profile.preferred_username,
email: profile.email,
image: profile.picture,
role: userRole,
isApproved: true,
// emailVerified wird vom PrismaAdapter erwartet (kann null sein).
// Mappe es, wenn Keycloak 'email_verified: true' sendet.
...(profile.email_verified && { emailVerified: new Date() }),
};
},
}),
CredentialsProvider({
name: "Credentials",
name: "Lokale Konten",
credentials: {
email: { label: "E-Mail", type: "email", placeholder: "user@example.com" },
password: { label: "Passwort", type: "password" }
@ -23,80 +60,53 @@ export const authOptions: AuthOptions = {
if (!credentials?.email || !credentials.password) {
throw new Error("E-Mail und Passwort sind erforderlich.");
}
const user = await prisma.user.findUnique({
where: { email: credentials.email.toLowerCase() } // E-Mail normalisieren für den Abgleich
where: { email: credentials.email.toLowerCase() }
});
if (!user || !user.password) {
// Benutzer nicht gefunden oder hat kein Passwort gesetzt (z.B. nur OAuth)
throw new Error("Benutzer nicht gefunden oder Passwort nicht gesetzt.");
}
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password
);
const isPasswordValid = await bcrypt.compare(credentials.password, user.password);
if (!isPasswordValid) {
throw new Error("E-Mail oder Passwort ist falsch.");
}
// NEU: Überprüfung des Freigabestatus
if (!user.isApproved) {
throw new Error("Dein Konto wurde noch nicht von einem Administrator freigegeben.");
}
// Stelle sicher, dass das zurückgegebene User-Objekt mit deiner
// erweiterten User-Definition in next-auth.d.ts übereinstimmt.
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
image: user.image,
isApproved: user.isApproved, // isApproved zum User-Objekt hinzufügen, das an jwt Callback geht
isApproved: user.isApproved,
};
}
}),
// Füge hier weitere Provider hinzu, z.B. Google:
// GoogleProvider({
// clientId: process.env.GOOGLE_CLIENT_ID,
// clientSecret: process.env.GOOGLE_CLIENT_SECRET,
// }),
],
callbacks: {
async jwt({ token, user }) {
// Das 'user'-Objekt ist hier das, was vom 'authorize'-Callback zurückgegeben wird.
// Es ist nur beim ersten Aufruf nach der Anmeldung (oder Token-Erstellung) vorhanden.
if (user) {
async jwt({ token, user, account }) {
if (account && user) {
token.id = user.id;
token.role = user.role;
token.isApproved = user.isApproved; // isApproved zum JWT hinzufügen
// Standard-Claims wie name, email, picture werden oft automatisch von NextAuth hinzugefügt,
// wenn sie im User-Objekt vorhanden sind.
token.isApproved = user.isApproved;
}
return token;
},
async session({ session, token }) {
// Das 'token'-Argument enthält die entschlüsselten Daten aus dem JWT.
if (token && session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string | undefined; // oder string | null, je nach Definition
session.user.isApproved = token.isApproved as boolean | undefined; // isApproved zur Session hinzufügen
// session.user.name = token.name; // Wird oft schon durch DefaultSession abgedeckt
// session.user.email = token.email; // Wird oft schon durch DefaultSession abgedeckt
// session.user.image = token.picture; // Wird oft schon durch DefaultSession abgedeckt (picture ist der JWT Claim für image)
session.user.role = token.role as string | undefined;
session.user.isApproved = token.isApproved as boolean | undefined;
}
return session;
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error', // Deine benutzerdefinierte Fehlerseite
error: '/auth/error',
},
secret: process.env.NEXTAUTH_SECRET, // Sollte in .env.local definiert sein
// debug: process.env.NODE_ENV === 'development',
secret: process.env.NEXTAUTH_SECRET,
};
export default NextAuth(authOptions);

View File

@ -1,17 +1,17 @@
// Datei: pages/auth/signin.tsx
import type { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next'; // GetServerSidePropsContext hinzugefügt
import { getProviders, signIn, useSession, getCsrfToken, getSession } from 'next-auth/react'; // getSession hinzugefügt
import type { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
import { getProviders, signIn, useSession, getCsrfToken, getSession } from 'next-auth/react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import type { ClientSafeProvider, LiteralUnion } from 'next-auth/react';
import type { ParsedUrlQuery } from 'querystring'; // Für GetServerSidePropsContext
import type { ParsedUrlQuery } from 'querystring';
import type { PreviewData } from 'next/dist/types';
import {BuiltInProviderType} from "next-auth/providers/index";
import Link from "next/link"; // Für GetServerSidePropsContext
import Link from "next/link";
interface SignInProps {
providers: Record<LiteralUnion<BuiltInProviderType, string>, ClientSafeProvider> | null;
csrfToken: string | undefined;
csrfToken: string | undefined; // csrfToken ist für Credentials-Formular-POSTs relevant, weniger für OAuth/OIDC Klicks
}
const SignInPage: NextPage<SignInProps> = ({ providers}) => {
@ -22,10 +22,10 @@ const SignInPage: NextPage<SignInProps> = ({ providers}) => {
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Fehler aus der URL-Query extrahieren (von NextAuth gesetzt)
useEffect(() => {
if (router.query.error) {
switch (router.query.error) {
if (router.query.error && !error) { // Nur setzen, wenn noch kein Fehler manuell gesetzt wurde
const errorKey = router.query.error as string;
switch (errorKey) {
case 'CredentialsSignin':
setError('Anmeldung fehlgeschlagen. Bitte überprüfe deine E-Mail und dein Passwort.');
break;
@ -34,24 +34,24 @@ const SignInPage: NextPage<SignInProps> = ({ providers}) => {
case 'OAuthCreateAccount':
case 'EmailCreateAccount':
case 'Callback':
setError('Fehler bei der OAuth-Anmeldung. Bitte versuche es erneut.');
case 'AccountNotLinked': // Häufiger Fehler bei OIDC, wenn E-Mail bereits existiert
setError(`Fehler bei der externen Anmeldung (${errorKey}). Existiert bereits ein Konto mit dieser E-Mail?`);
break;
case 'EmailSignin':
setError('Fehler beim Senden der Anmelde-E-Mail.');
break;
default:
setError(`Ein unbekannter Anmeldefehler ist aufgetreten: ${router.query.error}`);
setError(`Ein Anmeldefehler ist aufgetreten: ${errorKey}`);
break;
}
// Entferne den Fehler aus der URL, um ihn nicht erneut anzuzeigen, wenn der Benutzer interagiert
// Entferne den Fehler aus der URL, um ihn nicht erneut anzuzeigen
const newPath = router.pathname;
const queryWithoutError = { ...router.query };
delete queryWithoutError.error;
router.replace({ pathname: newPath, query: queryWithoutError }, undefined, { shallow: true });
}
}, [router.query.error, router]);
}, [router.query, error, router]); // error zur Dependency-Liste hinzugefügt
// Weiterleitung, wenn bereits authentifiziert
useEffect(() => {
if (status === 'authenticated') {
const callbackUrl = router.query.callbackUrl as string || '/dashboard';
@ -59,124 +59,63 @@ const SignInPage: NextPage<SignInProps> = ({ providers}) => {
}
}, [status, router]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleCredentialsSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null); // Reset error before new attempt
setError(null);
const result = await signIn('credentials', {
redirect: false, // Wir behandeln die Weiterleitung manuell oder warten auf den useEffect
redirect: false,
email: email,
password: password,
// callbackUrl: router.query.callbackUrl as string || '/dashboard' // Optional: hier schon setzen
});
setIsLoading(false);
if (result?.error) {
// Der Fehler wird auch über router.query.error gesetzt, aber wir können ihn hier direkt setzen
switch (result.error) {
case 'CredentialsSignin':
setError('Anmeldung fehlgeschlagen. Bitte überprüfe deine E-Mail und dein Passwort.');
break;
default:
setError(result.error || 'Ein unbekannter Fehler ist aufgetreten.');
// Fehlerbehandlung wie im useEffect, aber spezifischer für Credentials
if (result.error === "CredentialsSignin" || result.error.includes("Konto wurde noch nicht") || result.error.includes("E-Mail oder Passwort ist falsch")) {
setError(result.error); // Direkte Fehlermeldung von authorize
} else {
setError('Anmeldung fehlgeschlagen. Bitte überprüfe deine Eingaben.');
}
} else if (result?.ok && !result.error) {
// Erfolgreich, Weiterleitung wird durch useEffect oben oder durch callbackUrl in signIn gehandhabt
// router.push(router.query.callbackUrl as string || '/dashboard');
} else if (result?.ok) {
// Erfolg, Weiterleitung wird durch useEffect oben gehandhabt
}
};
if (status === 'loading' || status === 'authenticated') {
// Zeige Ladezustand, während auf Session-Status oder Weiterleitung gewartet wird
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<p className="text-lg text-gray-600">Laden...</p>
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<p className="text-lg text-gray-600 dark:text-gray-300">Laden...</p>
</div>
);
}
return (
<div className="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
{/* Optional: Logo hier einfügen */}
{/* <img className="mx-auto h-12 w-auto" src="/logo.svg" alt="Logo" /> */}
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Anmelden bei deiner Strichliste
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-gray-100">
Anmelden
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10">
<div className="bg-white dark:bg-slate-800 py-8 px-4 shadow-xl ring-1 ring-gray-900/10 dark:ring-gray-700 sm:rounded-lg sm:px-10">
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 border border-red-300 rounded-md text-sm">
<div className="mb-4 p-3 bg-red-100 text-red-700 border border-red-300 rounded-md text-sm dark:bg-red-900 dark:text-red-200 dark:border-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* CSRF Token ist nicht explizit für das manuelle signIn mit Credentials nötig,
aber NextAuth fügt es hinzu, wenn man die Standard-Submit-Action verwendet.
Da wir signIn('credentials', ...) verwenden, ist es implizit gehandhabt.
Wenn du action="/api/auth/callback/credentials" verwenden würdest, wäre es nötig:
<input name="csrfToken" type="hidden" defaultValue={csrfToken} />
*/}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
E-Mail-Adresse
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none text-black block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="deine@email.de"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Passwort
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none text-black block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isLoading ? 'Anmelden...' : 'Anmelden'}
</button>
</div>
</form>
{/* Buttons für andere Provider (z.B. Google, GitHub), falls konfiguriert */}
{/* Keycloak Provider Button (und andere OAuth/OIDC Provider) */}
{providers && Object.values(providers).map((provider) => {
if (provider.id === 'credentials') return null; // Credentials Provider ist das Formular oben
if (provider.id === 'credentials') return null; // Credentials Provider wird unten als Formular behandelt
return (
<div key={provider.name} className="mt-3">
<div key={provider.name} className="mb-3">
<button
onClick={() => signIn(provider.id, { callbackUrl: router.query.callbackUrl as string || '/dashboard' })}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-slate-700 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{/* Hier könntest du Provider-spezifische Icons hinzufügen */}
Mit {provider.name} anmelden
@ -184,10 +123,79 @@ const SignInPage: NextPage<SignInProps> = ({ providers}) => {
</div>
);
})}
{/* Trennlinie, wenn beide Arten von Providern vorhanden sind */}
{providers && Object.values(providers).some(p => p.id !== 'credentials') && Object.values(providers).some(p => p.id === 'credentials') && (
<div className="my-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-slate-800 text-gray-500 dark:text-gray-400">
Oder mit lokalen Konten
</span>
</div>
</div>
</div>
)}
{/* Credentials Provider Formular */}
{providers?.credentials && (
<form onSubmit={handleCredentialsSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
E-Mail-Adresse
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
placeholder="deine@email.de"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Passwort
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-600"
>
{isLoading ? 'Anmelden...' : 'Mit lokalen Konten anmelden'}
</button>
</div>
</form>
)}
<div className="mt-6 text-center text-sm">
<p className="text-gray-600">
Noch kein Konto?{' '}
<Link href="/auth/signup" className="font-medium text-indigo-600 hover:text-indigo-500">
<p className="text-gray-600 dark:text-gray-400">
Noch kein lokales Konto?{' '}
<Link href="/auth/signup" className="font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300">
Registrieren
</Link>
</p>
@ -201,9 +209,7 @@ const SignInPage: NextPage<SignInProps> = ({ providers}) => {
export const getServerSideProps: GetServerSideProps<SignInProps, ParsedUrlQuery, PreviewData> = async (
context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
) => {
// Wenn der Benutzer bereits angemeldet ist, leite ihn direkt zum Dashboard weiter.
// Dies verhindert ein kurzes Aufblitzen der Anmeldeseite.
const session = await getSession(context); // getSession importiert
const session = await getSession(context);
if (session) {
return {
redirect: {
@ -217,10 +223,11 @@ export const getServerSideProps: GetServerSideProps<SignInProps, ParsedUrlQuery,
const csrfToken = await getCsrfToken(context);
return {
props: {
providers: providers, // Korrigiert: providers direkt zuweisen (kann null sein)
providers: providers,
csrfToken: csrfToken ?? undefined,
},
};
};
export default SignInPage;