לדלג לתוכן

9.8 אותנטיקציה הרצאה

אותנטיקציה - Authentication

בשיעור זה נלמד להוסיף מערכת אותנטיקציה לאפליקציית Next.js באמצעות Auth.js (NextAuth.js). נבין את ההבדל בין אותנטיקציה להרשאה, ונממש התחברות עם ספקים שונים.


אותנטיקציה לעומת הרשאה

  • אותנטיקציה - Authentication: מי אתה? תהליך זיהוי המשתמש (התחברות)
  • הרשאה - Authorization: מה מותר לך? בדיקת הרשאות לפעולות ומשאבים
משתמש ──> אותנטיקציה (login) ──> הרשאה (מה מותר?) ──> גישה למשאב

מה זה Auth.js?

  • Auth.js (לשעבר NextAuth.js) היא ספרייה לאותנטיקציה ב-Next.js
  • תומכת בעשרות ספקים: Google, GitHub, Facebook, ועוד
  • תומכת בהתחברות עם אימייל/סיסמה (Credentials)
  • מנהלת סשנים אוטומטית
  • קלה להגדרה ולשימוש

התקנה והגדרה

npm install next-auth@beta

יצירת סוד

npx auth secret

זה מייצר 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 ולא רק על דפים