9.8 אותנטיקציה הרצאה
אותנטיקציה - Authentication¶
בשיעור זה נלמד להוסיף מערכת אותנטיקציה לאפליקציית Next.js באמצעות Auth.js (NextAuth.js). נבין את ההבדל בין אותנטיקציה להרשאה, ונממש התחברות עם ספקים שונים.
אותנטיקציה לעומת הרשאה¶
- אותנטיקציה - Authentication: מי אתה? תהליך זיהוי המשתמש (התחברות)
- הרשאה - Authorization: מה מותר לך? בדיקת הרשאות לפעולות ומשאבים
מה זה Auth.js?¶
- Auth.js (לשעבר NextAuth.js) היא ספרייה לאותנטיקציה ב-Next.js
- תומכת בעשרות ספקים: Google, GitHub, Facebook, ועוד
- תומכת בהתחברות עם אימייל/סיסמה (Credentials)
- מנהלת סשנים אוטומטית
- קלה להגדרה ולשימוש
התקנה והגדרה¶
יצירת סוד¶
זה מייצר AUTH_SECRET בקובץ .env.local.
הגדרת Auth.js¶
// auth.ts (ברמה הראשית)
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Credentials({
name: "credentials",
credentials: {
email: { label: "אימייל", type: "email" },
password: { label: "סיסמה", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!isValid) {
return null;
}
return {
id: user.id.toString(),
name: user.name,
email: user.email,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = (user as any).role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub!;
(session.user as any).role = token.role;
}
return session;
},
},
pages: {
signIn: "/login",
},
});
הגדרת Route Handler¶
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
משתני סביבה¶
# .env.local
AUTH_SECRET="generated-secret-here"
AUTH_URL="http://localhost:3000"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"
ספק אימייל/סיסמה - Credentials Provider¶
רישום משתמש חדש¶
// app/actions/auth.ts
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { redirect } from "next/navigation";
export async function register(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!name || !email || !password) {
return { error: "כל השדות הם חובה" };
}
if (password.length < 6) {
return { error: "הסיסמה חייבת להכיל לפחות 6 תווים" };
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return { error: "המשתמש כבר קיים" };
}
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.create({
data: { name, email, password: hashedPassword },
});
redirect("/login");
}
// app/register/page.tsx
import { register } from "@/app/actions/auth";
import Link from "next/link";
export default function RegisterPage() {
return (
<div className="max-w-md mx-auto p-6 mt-10">
<h1 className="text-3xl font-bold mb-6 text-center">הרשמה</h1>
<form action={register} className="space-y-4">
<div>
<label className="block font-semibold mb-1">שם</label>
<input
name="name"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block font-semibold mb-1">אימייל</label>
<input
name="email"
type="email"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block font-semibold mb-1">סיסמה</label>
<input
name="password"
type="password"
required
minLength={6}
className="w-full border rounded px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
הרשמה
</button>
</form>
<p className="text-center mt-4 text-gray-600">
יש לך חשבון?{" "}
<Link href="/login" className="text-blue-500 hover:underline">
התחבר
</Link>
</p>
</div>
);
}
דף התחברות¶
// app/login/page.tsx
import { signIn } from "@/auth";
import Link from "next/link";
export default function LoginPage() {
return (
<div className="max-w-md mx-auto p-6 mt-10">
<h1 className="text-3xl font-bold mb-6 text-center">התחברות</h1>
{/* התחברות עם ספקים */}
<div className="space-y-3 mb-6">
<form
action={async () => {
"use server";
await signIn("google", { redirectTo: "/dashboard" });
}}
>
<button
type="submit"
className="w-full border p-2 rounded flex items-center justify-center gap-2 hover:bg-gray-50"
>
התחבר עם Google
</button>
</form>
<form
action={async () => {
"use server";
await signIn("github", { redirectTo: "/dashboard" });
}}
>
<button
type="submit"
className="w-full border p-2 rounded flex items-center justify-center gap-2 hover:bg-gray-50"
>
התחבר עם GitHub
</button>
</form>
</div>
<div className="relative mb-6">
<hr />
<span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-2 text-gray-500">
או
</span>
</div>
{/* התחברות עם אימייל/סיסמה */}
<form
action={async (formData) => {
"use server";
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirectTo: "/dashboard",
});
}}
className="space-y-4"
>
<div>
<label className="block font-semibold mb-1">אימייל</label>
<input
name="email"
type="email"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block font-semibold mb-1">סיסמה</label>
<input
name="password"
type="password"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
התחבר
</button>
</form>
<p className="text-center mt-4 text-gray-600">
אין לך חשבון?{" "}
<Link href="/register" className="text-blue-500 hover:underline">
הירשם
</Link>
</p>
</div>
);
}
ניהול סשן - Session Management¶
בצד השרת - Server Components¶
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session) {
redirect("/login");
}
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-4">דשבורד</h1>
<p>שלום, {session.user?.name}</p>
<p>אימייל: {session.user?.email}</p>
</div>
);
}
בצד הקליינט - Client Components¶
// app/providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
// app/layout.tsx
import Providers from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="he" dir="rtl">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
// app/components/UserMenu.tsx
"use client";
import { useSession, signOut } from "next-auth/react";
import Link from "next/link";
export default function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div className="animate-pulse h-8 w-20 bg-gray-200 rounded" />;
}
if (!session) {
return (
<Link
href="/login"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
התחבר
</Link>
);
}
return (
<div className="flex items-center gap-4">
<span>שלום, {session.user?.name}</span>
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
התנתק
</button>
</div>
);
}
הגנה על נתיבים עם Middleware¶
// middleware.ts
import { auth } from "./auth";
import { NextResponse } from "next/server";
export default auth((request) => {
const { pathname } = request.nextUrl;
const isLoggedIn = !!request.auth;
// נתיבים מוגנים
const protectedPaths = ["/dashboard", "/settings", "/profile"];
const isProtected = protectedPaths.some((path) =>
pathname.startsWith(path)
);
if (isProtected && !isLoggedIn) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
// משתמש מחובר שמנסה לגשת לדף התחברות
if (pathname === "/login" && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/profile/:path*", "/login"],
};
הרשאות מבוססות תפקיד - Role-Based Access¶
הגדרת סכמה¶
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String
role String @default("user") // "user" | "admin" | "editor"
}
בדיקת תפקיד בשרת¶
// app/admin/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function AdminPage() {
const session = await auth();
if (!session) {
redirect("/login");
}
if ((session.user as any).role !== "admin") {
redirect("/dashboard");
}
return (
<div className="p-6">
<h1 className="text-3xl font-bold">פאנל ניהול</h1>
<p>ברוך הבא, מנהל!</p>
</div>
);
}
קומפוננטת הגנה¶
// app/components/RoleGuard.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
interface RoleGuardProps {
allowedRoles: string[];
children: React.ReactNode;
}
export default async function RoleGuard({
allowedRoles,
children,
}: RoleGuardProps) {
const session = await auth();
if (!session) {
redirect("/login");
}
const userRole = (session.user as any).role || "user";
if (!allowedRoles.includes(userRole)) {
redirect("/dashboard");
}
return <>{children}</>;
}
// app/admin/page.tsx
import RoleGuard from "@/app/components/RoleGuard";
export default function AdminPage() {
return (
<RoleGuard allowedRoles={["admin"]}>
<div className="p-6">
<h1 className="text-3xl font-bold">פאנל ניהול</h1>
</div>
</RoleGuard>
);
}
הגנה על Server Actions¶
// app/actions/admin.ts
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function deleteUser(userId: number) {
const session = await auth();
if (!session) {
throw new Error("לא מחובר");
}
if ((session.user as any).role !== "admin") {
throw new Error("אין הרשאה");
}
await prisma.user.delete({ where: { id: userId } });
}
דוגמה מלאה - Navbar עם אותנטיקציה¶
// app/components/Navbar.tsx
import { auth, signOut } from "@/auth";
import Link from "next/link";
export default async function Navbar() {
const session = await auth();
return (
<nav className="bg-gray-800 text-white p-4">
<div className="max-w-6xl mx-auto flex justify-between items-center">
<div className="flex gap-6">
<Link href="/" className="hover:text-gray-300">בית</Link>
{session && (
<>
<Link href="/dashboard" className="hover:text-gray-300">דשבורד</Link>
{(session.user as any).role === "admin" && (
<Link href="/admin" className="hover:text-gray-300">ניהול</Link>
)}
</>
)}
</div>
<div>
{session ? (
<div className="flex items-center gap-4">
<span>{session.user?.name}</span>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<button
type="submit"
className="px-3 py-1 bg-red-500 rounded hover:bg-red-600"
>
התנתק
</button>
</form>
</div>
) : (
<div className="flex gap-3">
<Link href="/login" className="hover:text-gray-300">התחבר</Link>
<Link href="/register" className="px-3 py-1 bg-blue-500 rounded hover:bg-blue-600">
הירשם
</Link>
</div>
)}
</div>
</div>
</nav>
);
}
סיכום¶
- אותנטיקציה = זיהוי (מי אתה), הרשאה = הרשאות (מה מותר)
- Auth.js מספק מערכת אותנטיקציה מלאה ל-Next.js
- תומך בספקים שונים: Google, GitHub, Credentials
- בצד השרת משתמשים ב-
auth()לקבלת הסשן - בצד הקליינט משתמשים ב-
useSessionמתוךSessionProvider - Middleware מגן על נתיבים שלמים
- הרשאות מבוססות תפקיד מאפשרות שליטה מדויקת בגישה
- חשוב להגן גם על Server Actions ולא רק על דפים