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:
- Local username/password authentication with proper password hashing
- JWT (JSON Web Token) for stateless authentication
- OAuth 2.0 integration with Google and GitHub
- Session-based authentication with secure cookies
- Two-factor authentication (2FA) with time-based one-time passwords (TOTP)
We'll implement this using Node.js, Express, MongoDB, and Passport.js, focusing on security best practices throughout.
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?
- User Convenience: Different users prefer different login methods
- Security in Depth: Implementing multiple layers of protection
- Flexibility: Supporting different client types (web, mobile, API consumers)
- Compliance: Meeting regulatory requirements for authentication
Security Requirements
- Secure password storage (hashing, not encryption)
- Protection against common attacks (brute force, credential stuffing, CSRF, XSS)
- Proper session management (secure cookies, expiration, rotation)
- Secure token handling (JWT signing, validation, expiration)
- Careful integration with third-party providers
- Implementation of multi-factor authentication
Real-World Authentication Flow
Think of authentication like airport security. When you arrive at an airport, you:
- Prove your identity: Show your passport or ID (similar to username/password or OAuth)
- Receive a token: Get a boarding pass (similar to JWT or session ID)
- Use the token for access: Present your boarding pass at the gate
- 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
- Set up the project structure and install dependencies
- Create the User model with password hashing
- Implement local authentication (username/password)
- Add JWT authentication
- Integrate OAuth providers (Google, GitHub)
- Implement session-based authentication
- Add two-factor authentication
- Create protected routes and testing pages
- Implement security measures (rate limiting, CSRF protection, etc.)
- Add frontend components for authentication flows
Key Technologies
- Node.js and Express: Server framework
- MongoDB and Mongoose: Database and ODM
- Passport.js: Authentication middleware
- bcrypt: Password hashing
- jsonwebtoken: JWT creation and verification
- express-session: Session management
- connect-mongo: Session storage
- passport-local, passport-jwt, passport-google-oauth20, passport-github2: Passport strategies
- speakeasy: TOTP for 2FA
- qrcode: QR code generation for 2FA setup
- express-rate-limit: Rate limiting
- helmet: Security headers
- csurf: CSRF protection
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:
- 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
- OAuth Authentication
- Login with Google
- Login with GitHub
- Test linking OAuth accounts to existing accounts
- Two-Factor Authentication
- Set up 2FA for a user
- Test login with 2FA verification
- Disable 2FA
- JWT Authentication
- Access protected routes using JWT token
- Test token expiration
- Session Authentication
- Access protected routes using session
- Test session timeout
Security Considerations
Let's verify that our authentication system implements these important security measures:
- Password Security: Passwords are securely hashed with bcrypt
- Brute Force Protection: Rate limiting and account locking after failed attempts
- Session Security: Secure cookies with httpOnly, secure, and sameSite flags
- CSRF Protection: Token validation for state-changing operations
- XSS Protection: Security headers with helmet.js
- Input Validation: Validation for all user inputs
- Error Handling: No sensitive information in error responses
- Multi-factor Authentication: Optional 2FA with TOTP
Improving the System
Here are some ways we could enhance our authentication system in the future:
- Email Service Integration: Connect to a service like SendGrid or Mailgun to send verification emails, password reset links, and security alerts
- Passwordless Authentication: Add magic link or WebAuthn (FIDO2) support
- More OAuth Providers: Add support for additional providers like Facebook, Twitter, Microsoft, Apple, etc.
- Account Activity Monitoring: Add logging for login attempts, password changes, and other security events
- Suspicious Activity Detection: Implement IP-based location tracking and unusual activity alerts
- Admin Dashboard: Create an interface for managing users and monitoring security events
- User Preferences: Allow users to manage their authentication methods, active sessions, and security settings
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:
- Knowledge: Something the user knows (password)
- Possession: Something the user has (phone for 2FA)
- Inherence: Something the user is (biometrics)
- Location: Where the user is (IP-based checks)
- Behavior: How the user interacts (typing patterns, mouse movements)
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:
- Local username/password authentication with secure password handling
- OAuth integration with Google and GitHub
- JWT-based authentication for stateless API access
- Session-based authentication for web applications
- Two-factor authentication for additional security
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.