Chapter 5: Authentication & Security ๐Ÿ”’

Our backend can now create, read, update, and delete data in a database via an API. But there's a huge problem: *anyone* can currently access these API endpoints! We need a way to verify who the user is and what they are allowed to do. This chapter introduces the critical concepts of **Authentication** and **Authorization**.

Theory: Authentication vs. Authorization (Who vs. What)

These two terms are often confused, but they are distinct:

  • ๐Ÿ” **Authentication (AuthN):** Verifying **who** a user is. This is the process of confirming a user's identity, usually by checking their username/email and password, or using methods like social login (Google, GitHub) or biometrics. It answers the question: "Are you really who you say you are?"
  • ๐Ÿ›ก๏ธ **Authorization (AuthZ):** Determining **what** an authenticated user is allowed to do. Once you know *who* the user is, authorization checks if they have permission to access a specific resource or perform a particular action (e.g., Is this user an admin? Can they edit *this specific* blog post?). It answers the question: "Are you allowed to do that?"

In this chapter, we'll focus primarily on **Authentication**, specifically handling user registration (signup) and login securely using password hashing and managing user sessions with JSON Web Tokens (JWT).

Task 1: Secure Password Handling with Hashing

Theory: Why Never Store Plain Text Passwords โŒ

Storing user passwords directly in your database as plain text (e.g., storing `password123` as `password123`) is **extremely dangerous**. If your database is ever compromised (hacked, leaked), all your users' passwords will be exposed. Since many people reuse passwords across different websites, this can lead to widespread account takeovers.

Theory: Hashing and Salting Explained๐Ÿง‚

  • **Hashing:** A one-way cryptographic process that converts an input (like a password) into a fixed-size string of characters (the "hash"). It's designed to be **irreversible** โ€“ you can't easily get the original password back from the hash. Good hashing algorithms always produce the same hash for the same input but radically different hashes even for slightly different inputs. Common algorithms include SHA-256, but for passwords, specialized algorithms like bcrypt or Argon2 are preferred.
  • **Salting:** Simply hashing isn't enough. If two users have the same password (`password123`), they would have the same hash. Attackers use pre-computed tables of common password hashes ("rainbow tables") to quickly find matches. **Salting** solves this. Before hashing the password, a unique, random string (the "salt") is added to it. This salt is then stored alongside the hash in the database. Now, even if two users have the same password, their salts will be different, resulting in completely different hashes, making rainbow table attacks ineffective.
[attachment_0](attachment)

Task: Implementing Hashing with `bcrypt`

The `bcrypt` library is a widely used and trusted Node.js package specifically designed for password hashing. It automatically handles salting for you.

How to Perform: Install `bcrypt`

In your Node.js project terminal:

npm install bcrypt

How to Perform: Hashing on User Signup

Modify your user creation route (`POST /api/users`) to hash the password before saving.

// In server.js (or wherever your user routes are)
const bcrypt = require('bcrypt'); // 1. Import bcrypt

// ... (Make sure User model and express.json() middleware are set up) ...

app.post('/api/users/signup', async (req, res) => { // Using a more specific route
  try {
    const { username, email, password, age } = req.body;

    // Basic Input Validation
    if (!username || !email || !password) {
      return res.status(400).json({ message: 'Username, email, and password are required' });
    }
    if (password.length < 6) { // Example: Enforce minimum password length
        return res.status(400).json({ message: 'Password must be at least 6 characters long' });
    }

    // 2. Generate a Salt and Hash the password
    const saltRounds = 10; // Cost factor: Higher = more secure but slower. 10 is a good default.
    const hashedPassword = await bcrypt.hash(password, saltRounds);

    // 3. Create new user with the HASHED password
    const newUser = new User({
      username,
      email,
      password: hashedPassword, // Store the hash, NOT the plain password
      age 
    });
    
    const savedUser = await newUser.save(); 
    
    // Important: Don't send the password hash back to the client!
    const userResponse = savedUser.toObject(); 
    delete userResponse.password; // Remove password hash before sending response
    delete userResponse.__v; 

    res.status(201).json(userResponse); 
  } catch (error) {
    if (error.code === 11000) { 
        return res.status(400).json({ message: 'Username or email already exists.' });
    }
    console.error('Signup Error:', error.message);
    res.status(400).json({ message: "Error creating user" }); 
  }
});

// Important: You'll also need to update your User Schema in Mongoose
// to include a 'password' field of type String and required: true.
// e.g., in userSchema definition:
// password: { type: String, required: true, minlength: 6 } 

**Theory:** `bcrypt.hash(plainPassword, saltRounds)` takes the user's plain password and the "cost factor" (how much computational effort to use) and returns a Promise that resolves with the generated hash (which includes the salt). We store this hash in the database.

How to Perform: Comparing Hashes on Login

When a user tries to log in, they send their email/username and plain password. We **do not** hash the submitted password and compare hashes. Instead, we fetch the user from the database (using their email/username) and use `bcrypt.compare()` to check if the submitted plain password matches the stored hash.

// Login Route (Example)
app.post('/api/users/login', async (req, res) => {
    try {
        const { email, password } = req.body;

        if (!email || !password) {
            return res.status(400).json({ message: 'Email and password are required' });
        }

        // 1. Find the user by email
        const user = await User.findOne({ email: email }); 
        if (!user) {
            // User not found - Generic error for security
            return res.status(401).json({ message: 'Invalid credentials' }); 
        }

        // 2. Compare the submitted password with the stored hash
        const isMatch = await bcrypt.compare(password, user.password); 
        
        if (!isMatch) {
            // Password doesn't match - Generic error
            return res.status(401).json({ message: 'Invalid credentials' });
        }

        // 3. --- Authentication Successful! --- 
        // DO NOT send the password hash back!
        const userResponse = user.toObject();
        delete userResponse.password;
        delete userResponse.__v;
        
        // Next Step: Generate a JWT (see below) and send it back
        // For now, just send a success message and user data
        res.status(200).json({ message: 'Login successful', user: userResponse }); 

    } catch (error) {
        console.error('Login Error:', error.message);
        res.status(500).json({ message: 'Internal server error during login' });
    }
});

**Theory:** `bcrypt.compare(plainPasswordSubmitted, hashFromDatabase)` performs the complex comparison, accounting for the salt embedded within the hash. It returns a Promise that resolves to `true` if they match, `false` otherwise. Notice we send a generic `Invalid credentials` message for both "user not found" and "wrong password" to prevent attackers from figuring out which emails are registered (user enumeration).

Task 2: Managing Sessions with JWT (JSON Web Tokens)

Theory: Why We Need Tokens (Statelessness)

Remember REST APIs are **stateless**? The server doesn't remember who made the last request. After a user logs in, how does the server know the *next* request is coming from that same authenticated user? We can't ask them for their password on every single request!

The solution is to give the user a temporary "proof of login" after they successfully authenticate. **JSON Web Tokens (JWTs)** are a popular standard for creating these proofs.

Theory: What is a JWT?

A JWT is a compact, URL-safe string that contains claims (information) about a user, digitally signed by the server. It has three parts separated by dots (`.`):

[attachment_1](attachment)
  1. **Header:** Contains metadata about the token (like the hashing algorithm used, e.g., HMAC SHA256).
  2. **Payload:** Contains the "claims" โ€“ information about the user (like `userId`, `username`, `role`) and token metadata (like expiration time `exp`). **Important:** This data is typically Base64Url encoded, *not* encrypted. Don't put sensitive data here unless the token itself is transmitted over HTTPS.
  3. **Signature:** Created by taking the encoded Header, the encoded Payload, a secret key (known *only* to the server), and signing them with the algorithm specified in the header.

The signature is key! When the client sends the token back to the server with a request, the server can use the secret key to verify that the signature is valid and that the Header and Payload haven't been tampered with.

Task: Implementing JWT with `jsonwebtoken`

How to Perform: Install `jsonwebtoken`

npm install jsonwebtoken

How to Perform: Generating a Token on Login

We'll modify the successful login part of our `/api/users/login` route.

const jwt = require('jsonwebtoken'); // 1. Import jsonwebtoken

// --- Inside your .env file, add a strong, random secret ---
// JWT_SECRET=your_super_secret_random_string_here_at_least_32_chars

// --- Update the Login Route ---
app.post('/api/users/login', async (req, res) => {
    try {
        const { email, password } = req.body;
        // ... (find user by email as before) ...
        const user = await User.findOne({ email: email }); 
        if (!user) {
            return res.status(401).json({ message: 'Invalid credentials' }); 
        }

        // ... (compare password as before) ...
        const isMatch = await bcrypt.compare(password, user.password); 
        if (!isMatch) {
            return res.status(401).json({ message: 'Invalid credentials' });
        }

        // --- Authentication Successful! Generate JWT --- 
        
        // 2. Create the Payload for the token
        const payload = {
            userId: user._id, // Include user ID (standard claim: 'sub' for subject)
            username: user.username 
            // Add other non-sensitive info if needed (e.g., roles)
        };

        // 3. Sign the token with your secret key
        // Make sure JWT_SECRET is loaded from your .env file!
        const secretKey = process.env.JWT_SECRET; 
        if (!secretKey) { throw new Error('JWT_SECRET is not defined!'); }

        const tokenOptions = { 
            expiresIn: '1h' // Token expires in 1 hour (e.g., '1d', '7d')
        };

        const token = jwt.sign(payload, secretKey, tokenOptions);

        // 4. Send the token back to the client
        res.status(200).json({ 
            message: 'Login successful', 
            token: token // Client needs to store this (e.g., in localStorage)
            // Optionally send back some user data too (excluding password)
            // user: { id: user._id, username: user.username, email: user.email }
        }); 

    } catch (error) {
        console.error('Login Error:', error.message);
        res.status(500).json({ message: 'Internal server error during login' });
    }
});

**Theory:** `jwt.sign(payload, secretKey, options)` creates the token. The client receives this token and typically stores it (e.g., in browser `localStorage` or `sessionStorage`). For subsequent requests to protected API routes, the client must send this token back, usually in the `Authorization` header like this: `Authorization: Bearer `.

**Security:** The `JWT_SECRET` **must be kept secret** on the server and should be a long, random, unpredictable string. Use environment variables (`.env` file, add to `.gitignore`)!

How to Perform: Verifying Tokens with Middleware

How do we protect routes so only logged-in users can access them? We create **middleware**. Middleware functions run *before* our route handlers. This middleware will check for a valid JWT in the request header.

// --- Middleware for protecting routes ---

// Function to verify JWT
function authenticateToken(req, res, next) {
    // 1. Get the token from the Authorization header
    const authHeader = req.headers['authorization'];
    // Header format is "Bearer TOKEN"
    const token = authHeader && authHeader.split(' ')[1]; 

    if (token == null) {
        // If no token, send 401 Unauthorized
        return res.status(401).json({ message: 'Authentication token required' }); 
    }

    // 2. Verify the token
    const secretKey = process.env.JWT_SECRET;
    jwt.verify(token, secretKey, (err, userPayload) => {
        if (err) {
            // If token is invalid or expired, send 403 Forbidden
            console.log('JWT Verification Error:', err.message);
            return res.status(403).json({ message: 'Invalid or expired token' }); 
        }

        // 3. If token is valid, attach payload to request object
        // Now downstream routes can access req.user
        req.user = userPayload; 
        
        // 4. Call next() to pass control to the next middleware or route handler
        next(); 
    });
}

// --- Applying the Middleware to Protect Routes ---

// Example: Get current user's profile (protected route)
// The authenticateToken middleware runs *before* the async (req, res) handler
app.get('/api/users/profile', authenticateToken, async (req, res) => {
    try {
        // We can access the user ID from the token payload attached by middleware
        const userId = req.user.userId; 
        
        const user = await User.findById(userId).select('-password -__v'); // Exclude sensitive fields
        
        if (!user) {
             return res.status(404).json({ message: 'User profile not found' });
        }
        res.status(200).json(user);

    } catch (error) {
        console.error('Profile Error:', error.message);
        res.status(500).json({ message: 'Error fetching user profile' });
    }
});

// Example: Making the GET /api/users route protected
// app.get('/api/users', authenticateToken, async (req, res) => { ... }); 

// Example: Making ALL routes below this point protected (Common pattern)
// app.use('/api/protected', authenticateToken); // All routes starting with /api/protected need a token
// app.get('/api/protected/some-data', (req, res) => { ... });

**Theory:** Middleware are functions with access to `req`, `res`, and a special function `next()`. `authenticateToken` checks the `Authorization` header, verifies the token using `jwt.verify()` and the *same secret key*. If valid, it attaches the decoded `userPayload` to `req.user` and calls `next()` to proceed to the actual route handler (like getting the profile). If invalid, it sends a `401` or `403` error response immediately, stopping the request from reaching the protected route.

Other Security Best Practices ๐Ÿ›ก๏ธ

  • **Use HTTPS:** Always serve your website and API over HTTPS to encrypt communication between the client and server. Services like Netlify, Vercel, and Heroku often handle this automatically.
  • **Input Validation:** Sanitize and validate *all* input from users (`req.body`, `req.params`, `req.query`) on the server-side to prevent injection attacks (like XSS or NoSQL Injection). Mongoose validation helps, but consider libraries like `express-validator`.
  • **Rate Limiting:** Protect against brute-force attacks by limiting how many login attempts or API requests a user can make in a given time (use packages like `express-rate-limit`).
  • **Update Dependencies:** Regularly update Node.js, Express, Mongoose, bcrypt, jsonwebtoken, and other packages to patch security vulnerabilities (`npm update` and check for security advisories with `npm audit`).
  • **Helmet.js:** Use the `helmet` middleware for Express to set various HTTP headers that improve security (protecting against common attacks like XSS, clickjacking, etc.). `npm install helmet`, then `app.use(helmet());` near the top of `server.js`.

Conclusion: Building Trustworthy Applications

Authentication and basic security are non-negotiable for any real-world application. You've learned how to securely store passwords using `bcrypt` and manage user sessions statelessly using `jsonwebtoken`. Implementing these correctly is crucial for protecting your users and your application.

The next steps involve exploring more advanced authorization (roles and permissions), implementing password reset flows, and understanding common web vulnerabilities in more detail.