Building a Secure Authentication System with Multiple Strategies

Weekend Project - Implementing a Robust Multi-Strategy Authentication System

Project Overview

Authentication is the cornerstone of web application security. In this weekend project, we'll build a complete authentication system that incorporates multiple authentication strategies, including:

We'll implement this using Node.js, Express, MongoDB, and Passport.js, focusing on security best practices throughout.

flowchart TD A[User Authentication Request] --> B{Authentication Type?} B -->|Local| C[Username/Password] B -->|Social| D[OAuth Provider] B -->|JWT| E[Verify Token] C --> F[Verify Password Hash] F -->|Success| G[Generate JWT or Session] F -->|Failure| H[Return Error] D --> I[Verify OAuth Response] I -->|Success| G I -->|Failure| H E -->|Valid| J[Access Granted] E -->|Invalid| H G --> K{2FA Enabled?} K -->|Yes| L[Verify TOTP] K -->|No| J L -->|Valid| J L -->|Invalid| H style J fill:#4CAF50,stroke:#388E3C,color:white style H fill:#F44336,stroke:#D32F2F,color:white

Understanding the Problem

Following George Polya's problem-solving method, let's first understand the problem at hand.

What is Authentication?

Authentication is the process of verifying the identity of a user, system, or entity. In web applications, it typically means confirming that users are who they claim to be before granting access to protected resources.

Why Use Multiple Authentication Strategies?

Security Requirements

Real-World Authentication Flow

Think of authentication like airport security. When you arrive at an airport, you:

  1. Prove your identity: Show your passport or ID (similar to username/password or OAuth)
  2. Receive a token: Get a boarding pass (similar to JWT or session ID)
  3. Use the token for access: Present your boarding pass at the gate
  4. Additional verification: Sometimes face additional security questions (like 2FA)

If any step fails, you're denied access. The system uses multiple checks to ensure only authorized people board the plane, just as we'll implement multiple strategies to protect our application.

Devising a Plan

Now that we understand the problem, let's devise a plan for building our authentication system.

Project Structure


secure-auth-system/
├── config/                 # Configuration files
│   ├── passport.js         # Passport.js configuration
│   ├── jwt.js              # JWT configuration
│   └── database.js         # Database connection
├── controllers/            # Route controllers
│   ├── authController.js   # Authentication logic
│   └── userController.js   # User management
├── middleware/             # Custom middleware
│   ├── auth.js             # Authentication middleware
│   └── rateLimiter.js      # Rate limiting for auth endpoints
├── models/                 # Database models
│   └── User.js             # User model with auth methods
├── routes/                 # API routes
│   ├── authRoutes.js       # Authentication routes
│   └── userRoutes.js       # User management routes
├── utils/                  # Utility functions
│   ├── validators.js       # Input validation
│   └── totp.js             # 2FA utilities
├── views/                  # Frontend templates (if using server-side rendering)
├── public/                 # Static assets
├── .env                    # Environment variables (not in git!)
├── server.js               # Application entry point
├── package.json            # Project dependencies
└── README.md               # Project documentation
            

Implementation Plan

  1. Set up the project structure and install dependencies
  2. Create the User model with password hashing
  3. Implement local authentication (username/password)
  4. Add JWT authentication
  5. Integrate OAuth providers (Google, GitHub)
  6. Implement session-based authentication
  7. Add two-factor authentication
  8. Create protected routes and testing pages
  9. Implement security measures (rate limiting, CSRF protection, etc.)
  10. Add frontend components for authentication flows

Key Technologies

Carrying Out the Plan

Let's implement our authentication system step by step.

Step 1: Project Setup

First, let's create our project and install the necessary dependencies:


# Create project directory
mkdir secure-auth-system
cd secure-auth-system

# Initialize npm project
npm init -y

# Install dependencies
npm install express mongoose bcrypt jsonwebtoken express-session 
npm install connect-mongo passport passport-local passport-jwt 
npm install passport-google-oauth20 passport-github2 speakeasy qrcode
npm install express-rate-limit helmet csurf dotenv cors ejs

# Install development dependencies
npm install nodemon --save-dev
                

Create a basic server.js file (in the root directory):


// server.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const passport = require('passport');
const session = require('express-session');
const MongoStore = require('connect-mongo');
const helmet = require('helmet');
const cors = require('cors');
const path = require('path');

// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;

// Connect to MongoDB
mongoose.connect(process.env.MONGODB_URI)
  .then(() => console.log('Connected to MongoDB'))
  .catch(err => console.error('MongoDB connection error:', err));

// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());
app.use(helmet()); // Security headers

// View engine setup (if using server-side rendering)
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));

// Session configuration
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  store: MongoStore.create({ 
    mongoUrl: process.env.MONGODB_URI,
    collectionName: 'sessions' 
  }),
  cookie: {
    maxAge: 1000 * 60 * 60 * 24, // 1 day
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  }
}));

// Initialize Passport
app.use(passport.initialize());
app.use(passport.session());

// Import Passport config
require('./config/passport');

// Routes (will add later)
app.get('/', (req, res) => {
  res.render('index', { user: req.user });
});

// Error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});

// Start the server
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
                

Create a .env file in the root directory (this file should be in .gitignore):


# .env
PORT=3000
MONGODB_URI=mongodb://localhost:27017/secure-auth-system
SESSION_SECRET=your_super_secret_session_key_change_in_production
JWT_SECRET=your_super_secret_jwt_key_change_in_production
JWT_EXPIRES_IN=1d

# OAuth Credentials
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

# Base URL
BASE_URL=http://localhost:3000
                

Step 2: Create the User Model

Let's create a User model that supports all our authentication methods:


// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const UserSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true
  },
  password: {
    type: String,
    // Required only for local strategy
    required: function() {
      return this.authStrategy === 'local';
    }
  },
  authStrategy: {
    type: String,
    required: true,
    enum: ['local', 'google', 'github', 'jwt'],
    default: 'local'
  },
  // For OAuth
  googleId: String,
  githubId: String,
  
  // For 2FA
  twoFactorEnabled: {
    type: Boolean,
    default: false
  },
  twoFactorSecret: String,
  
  // For account validation and recovery
  isVerified: {
    type: Boolean,
    default: false
  },
  verificationToken: String,
  resetPasswordToken: String,
  resetPasswordExpires: Date,
  
  // User information
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user'
  },
  lastLogin: Date,
  loginAttempts: {
    type: Number,
    default: 0
  },
  accountLocked: {
    type: Boolean,
    default: false
  },
  accountLockedUntil: Date
}, {
  timestamps: true // Adds createdAt and updatedAt fields
});

// Hash password before saving
UserSchema.pre('save', async function(next) {
  // Only hash the password if it's modified (or new)
  if (!this.isModified('password')) return next();
  
  try {
    // Generate a salt
    const salt = await bcrypt.genSalt(12);
    // Hash the password along with our new salt
    this.password = await bcrypt.hash(this.password, salt);
    next();
  } catch (error) {
    next(error);
  }
});

// Method to compare passwords
UserSchema.methods.comparePassword = async function(candidatePassword) {
  try {
    return await bcrypt.compare(candidatePassword, this.password);
  } catch (error) {
    throw error;
  }
};

// Method to handle login attempts and account locking
UserSchema.methods.recordLoginAttempt = async function(success) {
  // Reset login attempts if successful login
  if (success) {
    this.loginAttempts = 0;
    this.accountLocked = false;
    this.accountLockedUntil = null;
    this.lastLogin = new Date();
  } else {
    // Increment login attempts
    this.loginAttempts += 1;
    
    // Lock account after 5 failed attempts
    if (this.loginAttempts >= 5) {
      this.accountLocked = true;
      // Lock for 15 minutes
      this.accountLockedUntil = new Date(Date.now() + 15 * 60 * 1000);
    }
  }
  
  return this.save();
};

// Create and export the model
const User = mongoose.model('User', UserSchema);
module.exports = User;
                

Step 3: Configure Passport.js

Next, let's set up Passport.js with multiple authentication strategies:


// config/passport.js
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const GitHubStrategy = require('passport-github2').Strategy;
const User = require('../models/User');

// Serialize user for the session
passport.serializeUser((user, done) => {
  done(null, user.id);
});

// Deserialize user from the session
passport.deserializeUser(async (id, done) => {
  try {
    const user = await User.findById(id);
    done(null, user);
  } catch (error) {
    done(error);
  }
});

// Local Strategy (username/password)
passport.use(new LocalStrategy(
  { usernameField: 'email' },
  async (email, password, done) => {
    try {
      // Find the user by email
      const user = await User.findOne({ email });
      
      // If user doesn't exist
      if (!user) {
        return done(null, false, { message: 'Incorrect email or password' });
      }
      
      // Check if account is locked
      if (user.accountLocked) {
        if (user.accountLockedUntil > new Date()) {
          return done(null, false, { 
            message: 'Account locked. Try again later' 
          });
        } else {
          // Reset lock if lockout period has passed
          user.accountLocked = false;
          user.accountLockedUntil = null;
          await user.save();
        }
      }
      
      // Check if password is correct
      const isMatch = await user.comparePassword(password);
      if (!isMatch) {
        // Record failed login attempt
        await user.recordLoginAttempt(false);
        return done(null, false, { message: 'Incorrect email or password' });
      }
      
      // Record successful login
      await user.recordLoginAttempt(true);
      
      // Return the user
      return done(null, user);
    } catch (error) {
      return done(error);
    }
  }
));

// JWT Strategy
const jwtOptions = {
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
  secretOrKey: process.env.JWT_SECRET
};

passport.use(new JwtStrategy(jwtOptions, async (payload, done) => {
  try {
    // Find the user by ID in the JWT payload
    const user = await User.findById(payload.id);
    
    if (!user) {
      return done(null, false);
    }
    
    return done(null, user);
  } catch (error) {
    return done(error, false);
  }
}));

// Google OAuth Strategy
passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: `${process.env.BASE_URL}/auth/google/callback`
}, async (accessToken, refreshToken, profile, done) => {
  try {
    // Check if user already exists
    let user = await User.findOne({ googleId: profile.id });
    
    if (user) {
      // Update login timestamp
      user.lastLogin = new Date();
      await user.save();
      return done(null, user);
    }
    
    // Check if user exists with the same email
    user = await User.findOne({ email: profile.emails[0].value });
    
    if (user) {
      // Link Google account to existing user
      user.googleId = profile.id;
      user.lastLogin = new Date();
      await user.save();
      return done(null, user);
    }
    
    // Create a new user if it doesn't exist
    user = new User({
      name: profile.displayName,
      email: profile.emails[0].value,
      googleId: profile.id,
      authStrategy: 'google',
      isVerified: true, // Google already verified the email
      lastLogin: new Date()
    });
    
    await user.save();
    return done(null, user);
  } catch (error) {
    return done(error);
  }
}));

// GitHub OAuth Strategy
passport.use(new GitHubStrategy({
  clientID: process.env.GITHUB_CLIENT_ID,
  clientSecret: process.env.GITHUB_CLIENT_SECRET,
  callbackURL: `${process.env.BASE_URL}/auth/github/callback`,
  scope: ['user:email'] // Request email access
}, async (accessToken, refreshToken, profile, done) => {
  try {
    // Check if user already exists
    let user = await User.findOne({ githubId: profile.id });
    
    if (user) {
      // Update login timestamp
      user.lastLogin = new Date();
      await user.save();
      return done(null, user);
    }
    
    // Get email from GitHub profile
    let email = '';
    if (profile.emails && profile.emails.length > 0) {
      email = profile.emails[0].value;
    }
    
    // Check if user exists with the same email
    if (email) {
      user = await User.findOne({ email });
      
      if (user) {
        // Link GitHub account to existing user
        user.githubId = profile.id;
        user.lastLogin = new Date();
        await user.save();
        return done(null, user);
      }
    }
    
    // Create a new user if it doesn't exist
    user = new User({
      name: profile.displayName || profile.username,
      email: email || `${profile.username}@github.user`, // Fallback for users without public email
      githubId: profile.id,
      authStrategy: 'github',
      isVerified: true, // GitHub already verified the account
      lastLogin: new Date()
    });
    
    await user.save();
    return done(null, user);
  } catch (error) {
    return done(error);
  }
}));

module.exports = passport;
                

Step 4: Create JWT Utilities

Let's create a utility for JWT generation and validation:


// config/jwt.js
const jwt = require('jsonwebtoken');

// Generate JWT token
exports.generateToken = (user) => {
  return jwt.sign(
    { 
      id: user._id,
      email: user.email,
      role: user.role 
    },
    process.env.JWT_SECRET,
    { 
      expiresIn: process.env.JWT_EXPIRES_IN 
    }
  );
};

// Verify JWT token
exports.verifyToken = (token) => {
  try {
    return jwt.verify(token, process.env.JWT_SECRET);
  } catch (error) {
    throw error;
  }
};
                

Step 5: Create TOTP (2FA) Utilities

Let's add utilities for two-factor authentication:


// utils/totp.js
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// Generate TOTP secret
exports.generateSecret = (email) => {
  return speakeasy.generateSecret({
    name: `SecureAuthApp:${email}`
  });
};

// Verify TOTP token
exports.verifyToken = (secret, token) => {
  return speakeasy.totp.verify({
    secret: secret.base32,
    encoding: 'base32',
    token: token
  });
};

// Generate QR code for authenticator app
exports.generateQRCode = async (secret) => {
  try {
    return await QRCode.toDataURL(secret.otpauth_url);
  } catch (error) {
    throw error;
  }
};
                

Step 6: Create Authentication Controllers

Let's implement the authentication logic:


// controllers/authController.js
const passport = require('passport');
const User = require('../models/User');
const jwt = require('../config/jwt');
const totp = require('../utils/totp');
const crypto = require('crypto');

// Register new user
exports.register = async (req, res) => {
  try {
    const { name, email, password } = req.body;
    
    // Check if user already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ message: 'Email already in use' });
    }
    
    // Create verification token
    const verificationToken = crypto.randomBytes(20).toString('hex');
    
    // Create new user
    const user = new User({
      name,
      email,
      password,
      authStrategy: 'local',
      verificationToken
    });
    
    await user.save();
    
    // TODO: Send verification email
    
    res.status(201).json({ 
      message: 'User registered successfully. Please verify your email.' 
    });
  } catch (error) {
    res.status(500).json({ message: 'Error registering user', error: error.message });
  }
};

// Login with username/password
exports.login = (req, res, next) => {
  passport.authenticate('local', { session: false }, (err, user, info) => {
    if (err) {
      return next(err);
    }
    
    if (!user) {
      return res.status(401).json({ message: info.message });
    }
    
    // Check if email is verified
    if (!user.isVerified) {
      return res.status(401).json({ message: 'Please verify your email first' });
    }
    
    // Check if 2FA is enabled
    if (user.twoFactorEnabled) {
      req.session.twoFactorUser = user._id;
      return res.status(200).json({ 
        message: 'Please enter your 2FA code',
        require2FA: true
      });
    }
    
    // Generate JWT token
    const token = jwt.generateToken(user);
    
    // If using session-based auth, log in the user
    req.login(user, { session: true }, (err) => {
      if (err) {
        return next(err);
      }
      
      return res.status(200).json({ 
        message: 'Login successful',
        token,
        user: {
          id: user._id,
          name: user.name,
          email: user.email,
          role: user.role
        }
      });
    });
  })(req, res, next);
};

// Verify 2FA token
exports.verifyTwoFactor = async (req, res) => {
  try {
    const { token } = req.body;
    const userId = req.session.twoFactorUser;
    
    if (!userId) {
      return res.status(401).json({ message: 'Authentication required' });
    }
    
    const user = await User.findById(userId);
    if (!user) {
      return res.status(401).json({ message: 'User not found' });
    }
    
    // Verify TOTP token
    const isValid = totp.verifyToken(
      { base32: user.twoFactorSecret },
      token
    );
    
    if (!isValid) {
      return res.status(401).json({ message: 'Invalid 2FA code' });
    }
    
    // Clear 2FA session
    req.session.twoFactorUser = null;
    
    // Generate JWT token
    const jwtToken = jwt.generateToken(user);
    
    // If using session-based auth, log in the user
    req.login(user, { session: true }, (err) => {
      if (err) {
        return next(err);
      }
      
      return res.status(200).json({ 
        message: 'Login successful',
        token: jwtToken,
        user: {
          id: user._id,
          name: user.name,
          email: user.email,
          role: user.role
        }
      });
    });
  } catch (error) {
    res.status(500).json({ message: 'Error verifying 2FA', error: error.message });
  }
};

// Setup 2FA
exports.setupTwoFactor = async (req, res) => {
  try {
    // Ensure user is authenticated
    if (!req.user) {
      return res.status(401).json({ message: 'Authentication required' });
    }
    
    // Generate TOTP secret
    const secret = totp.generateSecret(req.user.email);
    
    // Generate QR code
    const qrCode = await totp.generateQRCode(secret);
    
    // Save secret to session temporarily
    req.session.twoFactorSecret = secret.base32;
    
    res.status(200).json({
      message: 'Two-factor authentication setup initiated',
      qrCode,
      secret: secret.base32 // For manual entry
    });
  } catch (error) {
    res.status(500).json({ message: 'Error setting up 2FA', error: error.message });
  }
};

// Verify and enable 2FA
exports.enableTwoFactor = async (req, res) => {
  try {
    const { token } = req.body;
    
    // Ensure user is authenticated
    if (!req.user) {
      return res.status(401).json({ message: 'Authentication required' });
    }
    
    // Get secret from session
    const secretFromSession = req.session.twoFactorSecret;
    if (!secretFromSession) {
      return res.status(400).json({ message: 'Two-factor setup not initiated' });
    }
    
    // Verify token
    const isValid = totp.verifyToken({ base32: secretFromSession }, token);
    
    if (!isValid) {
      return res.status(401).json({ message: 'Invalid verification code' });
    }
    
    // Save secret to user
    const user = await User.findById(req.user._id);
    user.twoFactorSecret = secretFromSession;
    user.twoFactorEnabled = true;
    await user.save();
    
    // Clear session data
    req.session.twoFactorSecret = null;
    
    res.status(200).json({ 
      message: 'Two-factor authentication enabled successfully' 
    });
  } catch (error) {
    res.status(500).json({ message: 'Error enabling 2FA', error: error.message });
  }
};

// Disable 2FA
exports.disableTwoFactor = async (req, res) => {
  try {
    // Ensure user is authenticated
    if (!req.user) {
      return res.status(401).json({ message: 'Authentication required' });
    }
    
    // Update user
    const user = await User.findById(req.user._id);
    user.twoFactorSecret = null;
    user.twoFactorEnabled = false;
    await user.save();
    
    res.status(200).json({ 
      message: 'Two-factor authentication disabled successfully' 
    });
  } catch (error) {
    res.status(500).json({ message: 'Error disabling 2FA', error: error.message });
  }
};

// Verify email
exports.verifyEmail = async (req, res) => {
  try {
    const { token } = req.params;
    
    // Find user with verification token
    const user = await User.findOne({ verificationToken: token });
    
    if (!user) {
      return res.status(400).json({ message: 'Invalid verification token' });
    }
    
    // Mark as verified
    user.isVerified = true;
    user.verificationToken = undefined;
    await user.save();
    
    res.status(200).json({ message: 'Email verified successfully' });
  } catch (error) {
    res.status(500).json({ message: 'Error verifying email', error: error.message });
  }
};

// Forgot password
exports.forgotPassword = async (req, res) => {
  try {
    const { email } = req.body;
    
    // Find user by email
    const user = await User.findOne({ email });
    
    if (!user) {
      // Don't reveal user existence, but still respond with 200
      return res.status(200).json({ 
        message: 'If the email exists, a reset link will be sent' 
      });
    }
    
    // Generate reset token
    const resetToken = crypto.randomBytes(20).toString('hex');
    
    // Set token and expiry
    user.resetPasswordToken = resetToken;
    user.resetPasswordExpires = Date.now() + 3600000; // 1 hour
    await user.save();
    
    // TODO: Send password reset email
    
    res.status(200).json({ 
      message: 'If the email exists, a reset link will be sent' 
    });
  } catch (error) {
    res.status(500).json({ message: 'Error processing request', error: error.message });
  }
};

// Reset password
exports.resetPassword = async (req, res) => {
  try {
    const { token } = req.params;
    const { password } = req.body;
    
    // Find user with valid reset token
    const user = await User.findOne({
      resetPasswordToken: token,
      resetPasswordExpires: { $gt: Date.now() }
    });
    
    if (!user) {
      return res.status(400).json({ message: 'Invalid or expired token' });
    }
    
    // Set new password
    user.password = password;
    user.resetPasswordToken = undefined;
    user.resetPasswordExpires = undefined;
    await user.save();
    
    res.status(200).json({ message: 'Password reset successful' });
  } catch (error) {
    res.status(500).json({ message: 'Error resetting password', error: error.message });
  }
};

// Logout
exports.logout = (req, res) => {
  req.logout(function(err) {
    if (err) { return next(err); }
    req.session.destroy(() => {
      res.clearCookie('connect.sid');
      res.status(200).json({ message: 'Logout successful' });
    });
  });
};

// Get current user
exports.getCurrentUser = (req, res) => {
  if (!req.user) {
    return res.status(401).json({ message: 'Not authenticated' });
  }
  
  res.status(200).json({
    user: {
      id: req.user._id,
      name: req.user.name,
      email: req.user.email,
      role: req.user.role,
      twoFactorEnabled: req.user.twoFactorEnabled
    }
  });
};
                

Step 7: Create Authentication Middleware

Let's create middleware for protecting routes:


// middleware/auth.js
const passport = require('passport');
const jwt = require('../config/jwt');

// Middleware to ensure user is authenticated with JWT
exports.authenticateJwt = passport.authenticate('jwt', { session: false });

// Middleware to ensure user is authenticated with session
exports.isAuthenticated = (req, res, next) => {
  if (req.isAuthenticated()) {
    return next();
  }
  
  res.status(401).json({ message: 'Not authenticated' });
};

// Middleware to check user role
exports.hasRole = (role) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ message: 'Not authenticated' });
    }
    
    if (req.user.role !== role) {
      return res.status(403).json({ message: 'Forbidden' });
    }
    
    next();
  };
};
                

Step 8: Create Rate Limiting Middleware

Let's add rate limiting to protect against brute force attacks:


// middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');

// General rate limiter
exports.generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
  message: 'Too many requests, please try again later'
});

// Login rate limiter
exports.loginLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 5, // limit each IP to 5 login requests per hour
  standardHeaders: true,
  message: 'Too many login attempts, please try again after an hour'
});

// Registration rate limiter
exports.registrationLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3, // limit each IP to 3 registrations per hour
  standardHeaders: true,
  message: 'Too many accounts created, please try again after an hour'
});

// Password reset rate limiter
exports.passwordResetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3, // limit each IP to 3 password reset requests per hour
  standardHeaders: true,
  message: 'Too many password reset requests, please try again after an hour'
});
                

Step 9: Create Authentication Routes

Now let's set up the routes for our authentication system:


// routes/authRoutes.js
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const auth = require('../middleware/auth');
const rateLimiter = require('../middleware/rateLimiter');
const passport = require('passport');

// Local authentication routes
router.post('/register', rateLimiter.registrationLimiter, authController.register);
router.post('/login', rateLimiter.loginLimiter, authController.login);
router.post('/logout', authController.logout);

// Email verification
router.get('/verify-email/:token', authController.verifyEmail);

// Password reset
router.post('/forgot-password', rateLimiter.passwordResetLimiter, 
  authController.forgotPassword);
router.post('/reset-password/:token', authController.resetPassword);

// Google OAuth routes
router.get('/google', passport.authenticate('google', { 
  scope: ['profile', 'email'] 
}));
router.get('/google/callback', passport.authenticate('google', { 
  failureRedirect: '/login', 
  session: true 
}), (req, res) => {
  // Successful authentication, redirect home or return JWT
  // For web clients, redirect to frontend with token
  res.redirect(`/auth-success?token=${jwt.generateToken(req.user)}`);
});

// GitHub OAuth routes
router.get('/github', passport.authenticate('github'));
router.get('/github/callback', passport.authenticate('github', { 
  failureRedirect: '/login', 
  session: true 
}), (req, res) => {
  // Successful authentication, redirect home or return JWT
  res.redirect(`/auth-success?token=${jwt.generateToken(req.user)}`);
});

// Two-factor authentication
router.post('/verify-2fa', authController.verifyTwoFactor);
router.post('/setup-2fa', auth.isAuthenticated, authController.setupTwoFactor);
router.post('/enable-2fa', auth.isAuthenticated, authController.enableTwoFactor);
router.post('/disable-2fa', auth.isAuthenticated, authController.disableTwoFactor);

// Current user
router.get('/me', auth.isAuthenticated, authController.getCurrentUser);

module.exports = router;
                

Step 10: Create Protected User Routes

Let's create some protected routes to test our authentication:


// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const User = require('../models/User');

// Get user profile (session auth)
router.get('/profile', auth.isAuthenticated, async (req, res) => {
  try {
    const user = await User.findById(req.user._id).select('-password -__v');
    res.status(200).json({ user });
  } catch (error) {
    res.status(500).json({ message: 'Error fetching profile', error: error.message });
  }
});

// Get user profile (JWT auth)
router.get('/profile/jwt', auth.authenticateJwt, async (req, res) => {
  try {
    const user = await User.findById(req.user._id).select('-password -__v');
    res.status(200).json({ user });
  } catch (error) {
    res.status(500).json({ message: 'Error fetching profile', error: error.message });
  }
});

// Admin-only route
router.get('/admin', [auth.isAuthenticated, auth.hasRole('admin')], (req, res) => {
  res.status(200).json({ message: 'Admin access granted' });
});

module.exports = router;
                

Step 11: Update the Main Server File

Finally, let's update our server.js file to include the routes:


// server.js (updated)
// ... (existing code)

// Import routes
const authRoutes = require('./routes/authRoutes');
const userRoutes = require('./routes/userRoutes');
const rateLimiter = require('./middleware/rateLimiter');

// Apply general rate limiter
app.use(rateLimiter.generalLimiter);

// Routes
app.use('/auth', authRoutes);
app.use('/user', userRoutes);

app.get('/', (req, res) => {
  res.render('index', { user: req.user });
});

// Auth success page (for OAuth redirects)
app.get('/auth-success', (req, res) => {
  res.render('auth-success', { token: req.query.token });
});

// ... (existing code)
                

Step 12: Create Basic Frontend Views

Let's create some basic frontend views for our authentication system:

views/index.ejs


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Secure Auth System</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <div class="container">
    <h1>Secure Authentication System</h1>
    
    <% if (user) { %>
      <div class="welcome">
        <h2>Welcome, <%= user.name %>!</h2>
        <p>Email: <%= user.email %></p>
        <p>Role: <%= user.role %></p>
        
        <div class="actions">
          <h3>Actions</h3>
          <ul>
            <li><a href="/user/profile">View Profile</a></li>
            <% if (!user.twoFactorEnabled) { %>
              <li><a href="/setup-2fa">Setup Two-Factor Authentication</a></li>
            <% } else { %>
              <li><a href="/disable-2fa">Disable Two-Factor Authentication</a></li>
            <% } %>
            <li>
              <form action="/auth/logout" method="POST">
                <button type="submit">Logout</button>
              </form>
            </li>
          </ul>
        </div>
      </div>
    <% } else { %>
      <div class="auth-options">
        <h2>Authentication Options</h2>
        
        <div class="card">
          <h3>Local Authentication</h3>
          <a href="/login" class="btn">Login</a>
          <a href="/register" class="btn">Register</a>
        </div>
        
        <div class="card">
          <h3>Social Authentication</h3>
          <a href="/auth/google" class="btn google">Login with Google</a>
          <a href="/auth/github" class="btn github">Login with GitHub</a>
        </div>
      </div>
    <% } %>
  </div>
  
  <script src="/js/main.js"></script>
</body>
</html>
                

views/login.ejs


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Login - Secure Auth System</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <div class="container">
    <h1>Login</h1>
    
    <div class="card auth-form">
      <form id="loginForm">
        <div class="form-group">
          <label for="email">Email</label>
          <input type="email" id="email" name="email" required>
        </div>
        
        <div class="form-group">
          <label for="password">Password</label>
          <input type="password" id="password" name="password" required>
        </div>
        
        <div class="form-group">
          <button type="submit">Login</button>
        </div>
        
        <div id="errorMessage" class="error-message"></div>
      </form>
      
      <div id="twoFactorForm" class="hidden">
        <h3>Two-Factor Authentication Required</h3>
        <div class="form-group">
          <label for="twoFactorToken">Enter code from your authenticator app</label>
          <input type="text" id="twoFactorToken" name="token" required>
        </div>
        
        <div class="form-group">
          <button id="verifyTwoFactorBtn">Verify</button>
        </div>
        
        <div id="twoFactorError" class="error-message"></div>
      </div>
    </div>
    
    <div class="auth-options">
      <h3>Or login with</h3>
      <a href="/auth/google" class="btn google">Google</a>
      <a href="/auth/github" class="btn github">GitHub</a>
    </div>
    
    <div class="links">
      <a href="/register">Don't have an account? Register</a>
      <a href="/forgot-password">Forgot password?</a>
      <a href="/">Back to home</a>
    </div>
  </div>
  
  <script>
    document.getElementById('loginForm').addEventListener('submit', async (e) => {
      e.preventDefault();
      
      const email = document.getElementById('email').value;
      const password = document.getElementById('password').value;
      const errorMessage = document.getElementById('errorMessage');
      
      try {
        const response = await fetch('/auth/login', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ email, password })
        });
        
        const data = await response.json();
        
        if (response.ok) {
          if (data.require2FA) {
            // Show 2FA form
            document.getElementById('loginForm').classList.add('hidden');
            document.getElementById('twoFactorForm').classList.remove('hidden');
          } else {
            // Store token in localStorage
            localStorage.setItem('authToken', data.token);
            
            // Redirect to home page
            window.location.href = '/';
          }
        } else {
          errorMessage.textContent = data.message || 'Login failed';
        }
      } catch (error) {
        errorMessage.textContent = 'An error occurred. Please try again.';
        console.error(error);
      }
    });
    
    document.getElementById('verifyTwoFactorBtn').addEventListener('click', async () => {
      const token = document.getElementById('twoFactorToken').value;
      const twoFactorError = document.getElementById('twoFactorError');
      
      try {
        const response = await fetch('/auth/verify-2fa', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ token })
        });
        
        const data = await response.json();
        
        if (response.ok) {
          // Store token in localStorage
          localStorage.setItem('authToken', data.token);
          
          // Redirect to home page
          window.location.href = '/';
        } else {
          twoFactorError.textContent = data.message || 'Verification failed';
        }
      } catch (error) {
        twoFactorError.textContent = 'An error occurred. Please try again.';
        console.error(error);
      }
    });
  </script>
</body>
</html>
                

views/register.ejs


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Register - Secure Auth System</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <div class="container">
    <h1>Register</h1>
    
    <div class="card auth-form">
      <form id="registerForm">
        <div class="form-group">
          <label for="name">Name</label>
          <input type="text" id="name" name="name" required>
        </div>
        
        <div class="form-group">
          <label for="email">Email</label>
          <input type="email" id="email" name="email" required>
        </div>
        
        <div class="form-group">
          <label for="password">Password</label>
          <input type="password" id="password" name="password" required>
          <small>Password must be at least 8 characters, including uppercase, lowercase, number, and special character</small>
        </div>
        
        <div class="form-group">
          <label for="confirmPassword">Confirm Password</label>
          <input type="password" id="confirmPassword" name="confirmPassword" required>
        </div>
        
        <div class="form-group">
          <button type="submit">Register</button>
        </div>
        
        <div id="errorMessage" class="error-message"></div>
        <div id="successMessage" class="success-message"></div>
      </form>
    </div>
    
    <div class="auth-options">
      <h3>Or register with</h3>
      <a href="/auth/google" class="btn google">Google</a>
      <a href="/auth/github" class="btn github">GitHub</a>
    </div>
    
    <div class="links">
      <a href="/login">Already have an account? Login</a>
      <a href="/">Back to home</a>
    </div>
  </div>
  
  <script>
    document.getElementById('registerForm').addEventListener('submit', async (e) => {
      e.preventDefault();
      
      const name = document.getElementById('name').value;
      const email = document.getElementById('email').value;
      const password = document.getElementById('password').value;
      const confirmPassword = document.getElementById('confirmPassword').value;
      const errorMessage = document.getElementById('errorMessage');
      const successMessage = document.getElementById('successMessage');
      
      // Clear previous messages
      errorMessage.textContent = '';
      successMessage.textContent = '';
      
      // Validate password
      const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
      if (!passwordPattern.test(password)) {
        errorMessage.textContent = 'Password must be at least 8 characters, including uppercase, lowercase, number, and special character';
        return;
      }
      
      // Validate password confirmation
      if (password !== confirmPassword) {
        errorMessage.textContent = 'Passwords do not match';
        return;
      }
      
      try {
        const response = await fetch('/auth/register', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ name, email, password })
        });
        
        const data = await response.json();
        
        if (response.ok) {
          successMessage.textContent = data.message;
          document.getElementById('registerForm').reset();
          
          // Redirect to login after 3 seconds
          setTimeout(() => {
            window.location.href = '/login';
          }, 3000);
        } else {
          errorMessage.textContent = data.message || 'Registration failed';
        }
      } catch (error) {
        errorMessage.textContent = 'An error occurred. Please try again.';
        console.error(error);
      }
    });
  </script>
</body>
</html>
                

views/setup-2fa.ejs


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Setup Two-Factor Authentication - Secure Auth System</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <div class="container">
    <h1>Setup Two-Factor Authentication</h1>
    
    <div class="card auth-form">
      <div id="setupStep1">
        <h3>Step 1: Scan the QR Code</h3>
        <p>Use an authenticator app such as Google Authenticator or Authy to scan the QR code below.</p>
        
        <div class="qr-container">
          <img id="qrCode" src="" alt="QR Code for 2FA">
        </div>
        
        <div class="manual-entry">
          <h4>Or enter this code manually:</h4>
          <code id="secretKey"></code>
        </div>
        
        <button id="continue2FA">Continue</button>
      </div>
      
      <div id="setupStep2" class="hidden">
        <h3>Step 2: Verify Code</h3>
        <p>Enter the 6-digit code from your authenticator app to verify setup.</p>
        
        <div class="form-group">
          <label for="verificationCode">Verification Code</label>
          <input type="text" id="verificationCode" name="token" inputmode="numeric" pattern="[0-9]*" maxlength="6" required>
        </div>
        
        <div class="form-group">
          <button id="verifyCodeBtn">Verify and Enable</button>
        </div>
        
        <div id="errorMessage" class="error-message"></div>
        <div id="successMessage" class="success-message"></div>
      </div>
    </div>
    
    <div class="links">
      <a href="/">Back to home</a>
    </div>
  </div>
  
  <script>
    // Fetch QR code and secret when page loads
    document.addEventListener('DOMContentLoaded', async () => {
      try {
        const response = await fetch('/auth/setup-2fa', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${localStorage.getItem('authToken')}`
          }
        });
        
        const data = await response.json();
        
        if (response.ok) {
          document.getElementById('qrCode').src = data.qrCode;
          document.getElementById('secretKey').textContent = data.secret;
        } else {
          alert('Error setting up 2FA: ' + data.message);
          window.location.href = '/';
        }
      } catch (error) {
        console.error(error);
        alert('Error setting up 2FA');
      }
    });
    
    // Continue to step 2
    document.getElementById('continue2FA').addEventListener('click', () => {
      document.getElementById('setupStep1').classList.add('hidden');
      document.getElementById('setupStep2').classList.remove('hidden');
    });
    
    // Verify the code and enable 2FA
    document.getElementById('verifyCodeBtn').addEventListener('click', async () => {
      const token = document.getElementById('verificationCode').value;
      const errorMessage = document.getElementById('errorMessage');
      const successMessage = document.getElementById('successMessage');
      
      // Clear previous messages
      errorMessage.textContent = '';
      successMessage.textContent = '';
      
      // Validate token format
      if (!/^\d{6}$/.test(token)) {
        errorMessage.textContent = 'Please enter a valid 6-digit code';
        return;
      }
      
      try {
        const response = await fetch('/auth/enable-2fa', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${localStorage.getItem('authToken')}`
          },
          body: JSON.stringify({ token })
        });
        
        const data = await response.json();
        
        if (response.ok) {
          successMessage.textContent = data.message;
          
          // Redirect to home after 3 seconds
          setTimeout(() => {
            window.location.href = '/';
          }, 3000);
        } else {
          errorMessage.textContent = data.message || 'Verification failed';
        }
      } catch (error) {
        errorMessage.textContent = 'An error occurred. Please try again.';
        console.error(error);
      }
    });
  </script>
</body>
</html>
                

views/auth-success.ejs


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Authentication Successful - Secure Auth System</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <div class="container">
    <h1>Authentication Successful</h1>
    
    <div class="card auth-success">
      <p>You have been successfully authenticated!</p>
      <p>Redirecting you to the home page...</p>
    </div>
  </div>
  
  <script>
    // Get token from URL
    const urlParams = new URLSearchParams(window.location.search);
    const token = urlParams.get('token');
    
    // Store token in localStorage
    if (token) {
      localStorage.setItem('authToken', token);
    }
    
    // Redirect to home page after 2 seconds
    setTimeout(() => {
      window.location.href = '/';
    }, 2000);
  </script>
</body>
</html>
                

Step 13: Create CSS Styles

Let's add some basic styles for our frontend:

public/css/style.css


/* Base styles */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  line-height: 1.6;
  color: #333;
  background-color: #f5f5f5;
}

.container {
  max-width: 1000px;
  margin: 0 auto;
  padding: 20px;
}

h1, h2, h3 {
  margin-bottom: 15px;
  color: #2c3e50;
}

a {
  color: #3498db;
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

/* Card styles */
.card {
  background-color: #fff;
  border-radius: 5px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  padding: 20px;
  margin-bottom: 20px;
}

/* Form styles */
.form-group {
  margin-bottom: 15px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: 600;
}

input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

button {
  background-color: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  padding: 10px 15px;
  font-size: 16px;
  cursor: pointer;
  transition: background-color 0.3s;
}

button:hover {
  background-color: #2980b9;
}

small {
  display: block;
  margin-top: 5px;
  color: #7f8c8d;
}

/* Authentication options */
.auth-options {
  margin: 20px 0;
  text-align: center;
}

.btn {
  display: inline-block;
  margin: 10px;
  padding: 10px 20px;
  background-color: #3498db;
  color: white;
  border-radius: 4px;
  text-decoration: none;
  transition: background-color 0.3s;
}

.btn:hover {
  background-color: #2980b9;
  text-decoration: none;
}

.btn.google {
  background-color: #dd4b39;
}

.btn.google:hover {
  background-color: #c23321;
}

.btn.github {
  background-color: #333;
}

.btn.github:hover {
  background-color: #000;
}

/* Messages */
.error-message {
  color: #e74c3c;
  margin: 10px 0;
}

.success-message {
  color: #2ecc71;
  margin: 10px 0;
}

/* Links section */
.links {
  margin-top: 20px;
  text-align: center;
}

.links a {
  display: block;
  margin: 10px 0;
}

/* Utility classes */
.hidden {
  display: none;
}

/* Two-factor authentication specific styles */
.qr-container {
  text-align: center;
  margin: 20px 0;
}

.qr-container img {
  max-width: 200px;
  border: 1px solid #ddd;
  padding: 10px;
}

.manual-entry {
  margin: 20px 0;
  padding: 15px;
  background-color: #f9f9f9;
  border-radius: 4px;
}

.manual-entry code {
  display: block;
  margin-top: 10px;
  padding: 10px;
  font-family: monospace;
  background-color: #eee;
  border-radius: 4px;
  font-size: 16px;
  word-break: break-all;
}

/* Welcome section */
.welcome {
  margin-bottom: 30px;
}

.actions h3 {
  border-bottom: 1px solid #ddd;
  padding-bottom: 10px;
  margin-top: 20px;
}

.actions ul {
  list-style: none;
  margin-top: 10px;
}

.actions li {
  margin-bottom: 10px;
}

.actions form {
  display: inline;
}
                

Looking Back and Testing

Now that we've implemented our secure authentication system, let's test it to ensure everything works as expected.

Starting the Server

Let's start our server and test the authentication system:


# Make sure you have MongoDB running

# Start the server
npm start
            

Testing Authentication Flows

Here's a checklist of features to test:

  1. Local Authentication
    • Register a new user
    • Verify email (in a real system, you'd click the link in the email)
    • Login with username/password
    • Test account locking after multiple failed attempts
    • Test password reset flow
  2. OAuth Authentication
    • Login with Google
    • Login with GitHub
    • Test linking OAuth accounts to existing accounts
  3. Two-Factor Authentication
    • Set up 2FA for a user
    • Test login with 2FA verification
    • Disable 2FA
  4. JWT Authentication
    • Access protected routes using JWT token
    • Test token expiration
  5. Session Authentication
    • Access protected routes using session
    • Test session timeout

Security Considerations

Let's verify that our authentication system implements these important security measures:

Improving the System

Here are some ways we could enhance our authentication system in the future:

Lessons Learned

Building a secure authentication system teaches us several important lessons:

Security in Layers

Authentication security is best implemented in layers. Each layer adds protection:

Trust No One

Always validate and verify every input and request. Assume all user input is potentially malicious and validate on both the client and server sides.

Balance Security and User Experience

Striking the right balance between security and usability is crucial. Too much security friction leads to user frustration, while too little leaves systems vulnerable.

Keep It Simple

Complex systems have more potential points of failure. Aim for simplicity in design and implementation while maintaining security. Use established libraries and follow best practices rather than creating custom solutions.

Plan for Failure

Always have recovery paths. Users will forget passwords, lose their phones, or encounter technical issues. Ensure you have secure recovery mechanisms in place.

Conclusion

In this weekend project, we've built a comprehensive authentication system that supports multiple strategies:

We've also implemented important security features like rate limiting, account locking, secure password reset flows, and protection against common attacks.

Authentication is a critical component of web application security, and implementing it correctly requires careful attention to detail. By following best practices and using established libraries like Passport.js, bcrypt, and jsonwebtoken, we can create a robust authentication system that protects our users' accounts while providing a positive user experience.

Remember that security is not a one-time implementation but an ongoing process. Stay informed about emerging threats and security best practices, and regularly update your authentication system to address new vulnerabilities and user needs.

Additional Resources