9.8 אותנטיקציה פתרון
פתרון - אותנטיקציה - Authentication¶
פתרון תרגיל 1 - הגדרת Auth.js¶
// 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>
);
}
תשובות לשאלות¶
-
ההבדל בין אותנטיקציה להרשאה: אותנטיקציה מזהה מי המשתמש (למשל, התחברות עם אימייל וסיסמה). הרשאה בודקת מה מותר למשתמש לעשות (למשל, רק admin יכול למחוק משתמשים). דוגמה: משתמש מתחבר (אותנטיקציה) ואז מנסה לגשת לפאנל ניהול (הרשאה).
-
למה חשוב להצפין סיסמאות: אם בסיס הנתונים נפרץ, הסיסמאות המוצפנות לא שימושיות לתוקף. bcrypt משתמש ב-salt ייחודי לכל סיסמה ומבצע חישוב איטי בכוונה, מה שמקשה על brute force. בלי הצפנה, כל המשתמשים חשופים מיד.
-
JWT session לעומת Database session: ב-JWT הסשן מאוחסן בטוקן שנשמר ב-cookie - אין צורך בשאילתת DB בכל בקשה, אבל אי אפשר לבטל סשן מרחוק. ב-Database session הסשן מאוחסן בבסיס הנתונים - אפשר לבטל ולנהל סשנים, אבל יש שאילתת DB בכל בקשה.
-
למה צריך להגן על Server Actions: Middleware מגן על גישה לדפים, אבל Server Actions הם endpoint-ים שאפשר לקרוא להם ישירות. משתמש יכול לקרוא ל-Server Action מבלי לעבור דרך הדף המוגן. לכן חייבים לבדוק אותנטיקציה גם בתוך כל Server Action.
-
callbackUrl: זהו הנתיב שאליו המשתמש ינותב אחרי התחברות מוצלחת. אם משתמש לא מחובר מנסה לגשת ל-
/dashboard/settings, הוא מופנה לדף ההתחברות עם?callbackUrl=/dashboard/settings. אחרי התחברות מוצלחת, הוא חוזר ישירות לדף שביקש ולא לדף ברירת מחדל.