לדלג לתוכן

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

פתרון - אותנטיקציה - Authentication

פתרון תרגיל 1 - הגדרת Auth.js

npm install next-auth@beta bcryptjs
npm install -D @types/bcryptjs
npx auth secret
// auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    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;
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string;
        (session.user as any).role = token.role;
      }
      return session;
    },
  },
  pages: {
    signIn: "/login",
  },
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";

export const { GET, POST } = handlers;

פתרון תרגיל 2 - רישום והתחברות

// app/actions/auth.ts
"use server";

import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { signIn } from "@/auth";
import { redirect } from "next/navigation";

interface AuthResult {
  success: boolean;
  error?: string;
}

export async function register(formData: FormData): Promise<AuthResult> {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  const confirmPassword = formData.get("confirmPassword") as string;

  if (!name || !email || !password || !confirmPassword) {
    return { success: false, error: "כל השדות הם חובה" };
  }

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) {
    return { success: false, error: "אימייל לא תקין" };
  }

  if (password.length < 6) {
    return { success: false, error: "הסיסמה חייבת להכיל לפחות 6 תווים" };
  }

  if (password !== confirmPassword) {
    return { success: false, error: "הסיסמאות לא תואמות" };
  }

  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) {
    return { success: false, error: "משתמש עם אימייל זה כבר קיים" };
  }

  const hashedPassword = await bcrypt.hash(password, 10);

  await prisma.user.create({
    data: { name, email, password: hashedPassword },
  });

  return { success: true };
}

export async function login(formData: FormData): Promise<AuthResult> {
  try {
    await signIn("credentials", {
      email: formData.get("email"),
      password: formData.get("password"),
      redirectTo: "/dashboard",
    });
    return { success: true };
  } catch (error: any) {
    if (error?.type === "CredentialsSignin") {
      return { success: false, error: "אימייל או סיסמה שגויים" };
    }
    throw error; // redirect errors
  }
}
// app/register/page.tsx
"use client";

import { useState } from "react";
import { register } from "@/app/actions/auth";
import Link from "next/link";
import { useRouter } from "next/navigation";

export default function RegisterPage() {
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();

  const handleSubmit = async (formData: FormData) => {
    setError(null);
    const result = await register(formData);
    if (!result.success) {
      setError(result.error || "שגיאה בהרשמה");
    } else {
      router.push("/login?registered=true");
    }
  };

  return (
    <div className="max-w-md mx-auto p-6 mt-10">
      <h1 className="text-3xl font-bold mb-6 text-center">הרשמה</h1>

      {error && (
        <div className="bg-red-50 text-red-600 p-3 rounded mb-4">{error}</div>
      )}

      <form action={handleSubmit} 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>
        <div>
          <label className="block font-semibold mb-1">אישור סיסמה</label>
          <input name="confirmPassword" 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="/login" className="text-blue-500 hover:underline">התחבר</Link>
      </p>
    </div>
  );
}
// app/login/page.tsx
"use client";

import { useState } from "react";
import { login } from "@/app/actions/auth";
import Link from "next/link";

export default function LoginPage() {
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (formData: FormData) => {
    setError(null);
    const result = await login(formData);
    if (!result.success) {
      setError(result.error || "שגיאה בהתחברות");
    }
  };

  return (
    <div className="max-w-md mx-auto p-6 mt-10">
      <h1 className="text-3xl font-bold mb-6 text-center">התחברות</h1>

      {error && (
        <div className="bg-red-50 text-red-600 p-3 rounded mb-4">{error}</div>
      )}

      <form action={handleSubmit} 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>
  );
}

פתרון תרגיל 3 - ניווט מותנה

// 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>
          <Link href="/about" className="hover:text-gray-300">אודות</Link>
          {session && (
            <>
              <Link href="/dashboard" className="hover:text-gray-300">דשבורד</Link>
              <Link href="/profile" 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 className="text-gray-300">שלום, {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 text-sm"
                >
                  התנתק
                </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>
  );
}

פתרון תרגיל 4 - הגנה על נתיבים

// middleware.ts
import { auth } from "./auth";
import { NextResponse } from "next/server";

export default auth((request) => {
  const { pathname } = request.nextUrl;
  const isLoggedIn = !!request.auth;
  const role = (request.auth?.user as any)?.role;

  // נתיבים מוגנים
  const protectedPaths = ["/dashboard", "/settings", "/profile"];
  const isProtected = protectedPaths.some((p) => pathname.startsWith(p));

  if (isProtected && !isLoggedIn) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("callbackUrl", pathname);
    return NextResponse.redirect(loginUrl);
  }

  // נתיבי admin
  if (pathname.startsWith("/admin")) {
    if (!isLoggedIn) {
      return NextResponse.redirect(new URL("/login", request.url));
    }
    if (role !== "admin") {
      return NextResponse.redirect(new URL("/dashboard", request.url));
    }
  }

  // משתמש מחובר בדפי auth
  if ((pathname === "/login" || pathname === "/register") && isLoggedIn) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next();
});

export const config = {
  matcher: [
    "/dashboard/:path*",
    "/settings/:path*",
    "/profile/:path*",
    "/admin/:path*",
    "/login",
    "/register",
  ],
};

פתרון תרגיל 5 - פרופיל משתמש

// app/actions/profile.ts
"use server";

import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";

export async function updateName(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("לא מחובר");

  const name = formData.get("name") as string;
  if (!name || name.trim().length < 2) {
    return { error: "השם חייב להכיל לפחות 2 תווים" };
  }

  await prisma.user.update({
    where: { id: parseInt(session.user.id) },
    data: { name: name.trim() },
  });

  revalidatePath("/profile");
  return { success: true };
}

export async function changePassword(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("לא מחובר");

  const currentPassword = formData.get("currentPassword") as string;
  const newPassword = formData.get("newPassword") as string;
  const confirmPassword = formData.get("confirmPassword") as string;

  if (!currentPassword || !newPassword || !confirmPassword) {
    return { error: "כל השדות הם חובה" };
  }

  if (newPassword.length < 6) {
    return { error: "הסיסמה חדשה חייבת להכיל לפחות 6 תווים" };
  }

  if (newPassword !== confirmPassword) {
    return { error: "הסיסמאות החדשות לא תואמות" };
  }

  const user = await prisma.user.findUnique({
    where: { id: parseInt(session.user.id) },
  });

  if (!user) return { error: "משתמש לא נמצא" };

  const isValid = await bcrypt.compare(currentPassword, user.password);
  if (!isValid) return { error: "הסיסמה הנוכחית שגויה" };

  const hashedPassword = await bcrypt.hash(newPassword, 10);

  await prisma.user.update({
    where: { id: user.id },
    data: { password: hashedPassword },
  });

  return { success: true, message: "הסיסמה שונתה בהצלחה" };
}
// app/profile/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import ProfileForm from "./ProfileForm";
import PasswordForm from "./PasswordForm";

export default async function ProfilePage() {
  const session = await auth();
  if (!session) redirect("/login");

  const user = await prisma.user.findUnique({
    where: { id: parseInt(session.user.id) },
    select: { name: true, email: true, createdAt: true, role: true },
  });

  if (!user) redirect("/login");

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">פרופיל</h1>

      <div className="bg-gray-50 p-4 rounded mb-6">
        <h2 className="font-bold mb-2">פרטים</h2>
        <p>שם: {user.name}</p>
        <p>אימייל: {user.email}</p>
        <p>תפקיד: {user.role}</p>
        <p>הצטרף: {new Date(user.createdAt).toLocaleDateString("he-IL")}</p>
      </div>

      <ProfileForm currentName={user.name} />
      <PasswordForm />
    </div>
  );
}

פתרון תרגיל 6 - פאנל ניהול

// app/actions/admin.ts
"use server";

import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

async function requireAdmin() {
  const session = await auth();
  if (!session) throw new Error("לא מחובר");
  if ((session.user as any).role !== "admin") throw new Error("אין הרשאה");
  return session;
}

export async function changeUserRole(userId: number, newRole: string) {
  await requireAdmin();

  await prisma.user.update({
    where: { id: userId },
    data: { role: newRole },
  });

  revalidatePath("/admin");
}

export async function deleteUser(userId: number) {
  const session = await requireAdmin();

  if (parseInt(session.user.id) === userId) {
    return { error: "אי אפשר למחוק את עצמך" };
  }

  await prisma.user.delete({ where: { id: userId } });
  revalidatePath("/admin");
  return { success: true };
}
// app/admin/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import UserRow from "./UserRow";

export default async function AdminPage() {
  const session = await auth();
  if (!session) redirect("/login");
  if ((session.user as any).role !== "admin") redirect("/dashboard");

  const users = await prisma.user.findMany({
    orderBy: { createdAt: "desc" },
    select: { id: true, name: true, email: true, role: true, createdAt: true },
  });

  const stats = {
    total: users.length,
    admins: users.filter((u) => u.role === "admin").length,
    regular: users.filter((u) => u.role === "user").length,
  };

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">פאנל ניהול</h1>

      <div className="grid grid-cols-3 gap-4 mb-8">
        <div className="bg-blue-50 p-4 rounded text-center">
          <div className="text-2xl font-bold">{stats.total}</div>
          <div>סה"כ משתמשים</div>
        </div>
        <div className="bg-purple-50 p-4 rounded text-center">
          <div className="text-2xl font-bold">{stats.admins}</div>
          <div>מנהלים</div>
        </div>
        <div className="bg-green-50 p-4 rounded text-center">
          <div className="text-2xl font-bold">{stats.regular}</div>
          <div>משתמשים רגילים</div>
        </div>
      </div>

      <table className="w-full border-collapse">
        <thead>
          <tr className="bg-gray-100">
            <th className="p-3 text-right">שם</th>
            <th className="p-3 text-right">אימייל</th>
            <th className="p-3 text-right">תפקיד</th>
            <th className="p-3 text-right">תאריך</th>
            <th className="p-3 text-right">פעולות</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <UserRow
              key={user.id}
              user={user}
              isCurrentUser={user.id === parseInt(session.user.id)}
            />
          ))}
        </tbody>
      </table>
    </div>
  );
}

תשובות לשאלות

  1. ההבדל בין אותנטיקציה להרשאה: אותנטיקציה מזהה מי המשתמש (למשל, התחברות עם אימייל וסיסמה). הרשאה בודקת מה מותר למשתמש לעשות (למשל, רק admin יכול למחוק משתמשים). דוגמה: משתמש מתחבר (אותנטיקציה) ואז מנסה לגשת לפאנל ניהול (הרשאה).

  2. למה חשוב להצפין סיסמאות: אם בסיס הנתונים נפרץ, הסיסמאות המוצפנות לא שימושיות לתוקף. bcrypt משתמש ב-salt ייחודי לכל סיסמה ומבצע חישוב איטי בכוונה, מה שמקשה על brute force. בלי הצפנה, כל המשתמשים חשופים מיד.

  3. JWT session לעומת Database session: ב-JWT הסשן מאוחסן בטוקן שנשמר ב-cookie - אין צורך בשאילתת DB בכל בקשה, אבל אי אפשר לבטל סשן מרחוק. ב-Database session הסשן מאוחסן בבסיס הנתונים - אפשר לבטל ולנהל סשנים, אבל יש שאילתת DB בכל בקשה.

  4. למה צריך להגן על Server Actions: Middleware מגן על גישה לדפים, אבל Server Actions הם endpoint-ים שאפשר לקרוא להם ישירות. משתמש יכול לקרוא ל-Server Action מבלי לעבור דרך הדף המוגן. לכן חייבים לבדוק אותנטיקציה גם בתוך כל Server Action.

  5. callbackUrl: זהו הנתיב שאליו המשתמש ינותב אחרי התחברות מוצלחת. אם משתמש לא מחובר מנסה לגשת ל-/dashboard/settings, הוא מופנה לדף ההתחברות עם ?callbackUrl=/dashboard/settings. אחרי התחברות מוצלחת, הוא חוזר ישירות לדף שביקש ולא לדף ברירת מחדל.