הרשמה והתחברות (bcrypt ו-JWT)

כמעט כל אפליקציה צריכה משתמשים: הרשמה, התחברות, ונתיבים שרק משתמש מחובר יכול לגשת אליהם. בשיעור הזה נבנה את זה נכון, עם שני כלים: bcrypt לשמירת סיסמאות, ו-JWT לזיהוי המשתמש בבקשות הבאות.

חשוב לדעת: אבטחה היא תחום שבו טעות קטנה עולה ביוקר. השיעור מלמד את העקרונות. באפליקציה אמיתית שווה לשקול גם שירות מוכן (Auth0, Clerk, Firebase Auth) או ספרייה בשלה (Better Auth, Passport).

לעולם לא לשמור סיסמה כמו שהיא

אם המסד דולף, כל הסיסמאות חשופות, ורוב האנשים משתמשים באותה סיסמה בכמה אתרים. לכן שומרים רק hash: תוצאה של פונקציה חד-כיוונית, שאי אפשר לשחזר ממנה את הסיסמה. bcrypt גם מוסיף "מלח" אקראי לכל סיסמה, ומכוון להיות איטי, כדי שניחוש בכוח יהיה יקר.

npm install bcryptjs jsonwebtoken
import bcrypt from 'bcryptjs';

const hash = await bcrypt.hash('my-secret-password', 12);   // 12 = כמה עבודה (עלות)
// מחרוזת שמתחילה ב-$2, עם המלח והעלות בתוכה - זה מה שנשמר במסד

const isCorrect = await bcrypt.compare('my-secret-password', hash);  // true

הרשמה

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
// src/auth/auth.routes.js
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { pool } from '../db.js';
import { AppError } from '../errors.js';

export const authRouter = Router();

authRouter.post('/register', async (req, res) => {
    const email = String(req.body.email ?? '').trim().toLowerCase();
    const password = String(req.body.password ?? '');
    if (!email.includes('@')) throw new AppError('אימייל לא תקין', 400);
    if (password.length < 10) throw new AppError('סיסמה חייבת לפחות 10 תווים', 400);

    const passwordHash = await bcrypt.hash(password, 12);
    try {
        const [result] = await pool.query(
            'INSERT INTO users (email, password_hash) VALUES (?, ?)', [email, passwordHash]
        );
        res.status(201).json({ id: result.insertId, email });
    } catch (error) {
        if (error.code === 'ER_DUP_ENTRY') throw new AppError('האימייל כבר רשום', 409);
        throw error;
    }
});

מה זה JWT

אחרי התחברות, השרת צריך לזהות את המשתמש בכל בקשה בלי לבקש שוב סיסמה. JWT (JSON Web Token) הוא מחרוזת עם שלושה חלקים: מידע (למשל מזהה המשתמש), תוקף, וחתימה שרק השרת יודע לייצר עם מפתח סודי. אם מישהו משנה אפילו תו אחד, החתימה לא תתאים.

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

התחברות

# .env - מחרוזת אקראית ארוכה. לייצר, למשל, עם:
# node -e "console.log(crypto.randomBytes(48).toString('hex'))"
JWT_SECRET=...
authRouter.post('/login', async (req, res) => {
    const email = String(req.body.email ?? '').trim().toLowerCase();
    const [rows] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
    const user = rows[0];

    // אותה הודעה לשני המקרים: לא מגלים אם האימייל קיים במערכת
    const valid = user && await bcrypt.compare(String(req.body.password ?? ''), user.password_hash);
    if (!valid) throw new AppError('אימייל או סיסמה שגויים', 401);

    const token = jwt.sign({ sub: String(user.id) }, process.env.JWT_SECRET, { expiresIn: '1h' });
    res.json({ token });
});

Middleware שמגן על נתיבים

// src/auth/require-auth.js
import jwt from 'jsonwebtoken';
import { AppError } from '../errors.js';

export function requireAuth(req, res, next) {
    const header = req.get('Authorization') ?? '';
    const token = header.startsWith('Bearer ') ? header.slice(7) : null;
    if (!token) throw new AppError('נדרשת התחברות', 401);

    try {
        const payload = jwt.verify(token, process.env.JWT_SECRET);
        req.userId = Number(payload.sub);
        next();
    } catch (error) {
        throw new AppError('ההתחברות פגה או לא תקינה', 401);
    }
}
// src/app.js
app.use('/api/auth', authRouter);
app.use('/api/todos', requireAuth, todosRouter);   // כל נתיבי המשימות דורשים התחברות

// בתוך הנתיבים: רק המשימות של המשתמש המחובר
todosRouter.get('/', async (req, res) => {
    const [rows] = await pool.query('SELECT * FROM todos WHERE user_id = ?', [req.userId]);
    res.json(rows);
});

כדי שלכל משתמש יהיו משימות משלו, מוסיפים לטבלה עמודה שמקשרת אותה למשתמש:

ALTER TABLE todos ADD COLUMN user_id INT NOT NULL,
    ADD FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;

הלקוח שולח את הטוקן בכל בקשה: Authorization: Bearer eyJhbGciOi....

איפה הדפדפן שומר את הטוקן

אפשרותיתרוןסיכון
Cookie עם httpOnlyJavaScript לא יכול לקרוא אותו, ולכן XSS לא גונב אותוצריך הגנת CSRF (sameSite)
localStorageפשוט לשימוש מ-Reactכל סקריפט בדף (גם זדוני) יכול לקרוא אותו

לאפליקציית ווב, cookie עם httpOnly, secure ו-sameSite: 'lax' הוא הבחירה הבטוחה יותר. Authorization header מתאים לאפליקציות מובייל ולקוחות שאינם דפדפן.

רשימת בדיקה

  • סיסמאות: רק hash (bcrypt או argon2), אף פעם לא טקסט רגיל, ואף פעם לא בלוג.
  • JWT_SECRET ארוך ואקראי, ב-.env ולא בקוד.
  • תוקף קצר לטוקן, והודעת שגיאה זהה לאימייל שגוי ולסיסמה שגויה.
  • HTTPS בפרודקשן, תמיד.
  • הגבלת ניסיונות התחברות, כדי שאי אפשר יהיה לנחש סיסמאות. על זה בשיעור הבא.

בדקו את עצמכם

נסו לענות לבד לפני שאתם פותחים את התשובה.

  1. איך שומרים סיסמאות במסד?

    1. רק hash עם bcrypt או argon2
    2. כמו שהן
    3. מוצפנות עם מפתח
    4. ב-base64
    הצגת התשובה

    תשובה א. hash חד-כיווני עם מלח: אי אפשר לשחזר ממנו את הסיסמה.

  2. האם התוכן של JWT מוצפן?

    1. רק עם HTTPS
    2. כן
    3. רק בפרודקשן
    4. לא: הוא קריא לכל אחד, רק חתום נגד שינויים
    הצגת התשובה

    תשובה ד. לכן לא שמים ב-JWT מידע רגיש.

  3. למה להחזיר אותה הודעה לאימייל שגוי ולסיסמה שגויה?

    1. חוק
    2. כדי לא לגלות לתוקף אילו אימיילים רשומים במערכת
    3. סגנון
    4. זה קל יותר
    הצגת התשובה

    תשובה ב. הודעות שונות מאפשרות לאסוף רשימת משתמשים.