Mastering Validations and Authentication in Sequelize

A comprehensive guide for new developers

Introduction

Welcome to this hands-on tutorial where we'll explore two critical aspects of building robust web applications with Sequelize: data validation and user authentication. These concepts are the guardians of your application's integrity and security.

The Doorkeeper Analogy

Think of validations as a meticulous doorkeeper at an exclusive venue. This doorkeeper carefully checks each guest (your data) against a list of requirements before allowing entry. Is the email properly formatted? Is the password strong enough? Is the username unique? Just as the doorkeeper maintains the quality of guests at the venue, validations maintain the quality of data in your database.

Authentication, on the other hand, is like the VIP pass verification system. Once someone claims to be on the guest list, the system verifies their identity before granting access to restricted areas. In our applications, this means verifying that users are who they claim to be before allowing them to access protected resources.

Setting Up Your Project

Let's begin by setting up a new Node.js project with Sequelize. Make sure you have Node.js and npm installed on your system.

mkdir sequelize_auth_project
cd sequelize_auth_project
npm init -y
npm install sequelize mysql2 express bcrypt jsonwebtoken dotenv
npm install --save-dev nodemon
                

This will create the following folder structure:

sequelize_auth_project/
├── node_modules/
├── package.json
├── package-lock.json
                

Now, let's create the necessary files and folders for our project:

mkdir config models routes controllers
touch .env app.js
                

Your folder structure should now look like this:

sequelize_auth_project/
├── config/
├── controllers/
├── models/
├── node_modules/
├── routes/
├── .env
├── app.js
├── package.json
├── package-lock.json
                

Database Configuration

Let's set up our database configuration. Create a file in the config folder called database.js:

config/database.js


const { Sequelize } = require('sequelize');
require('dotenv').config();

const sequelize = new Sequelize(
    process.env.DB_NAME,
    process.env.DB_USER,
    process.env.DB_PASSWORD,
    {
        host: process.env.DB_HOST,
        dialect: 'mysql',
        logging: false // Set to console.log to see SQL queries
    }
);

module.exports = sequelize;
                

Now, let's set up our environment variables in the .env file:

.env


DB_NAME=sequelize_auth_db
DB_USER=root
DB_PASSWORD=your_password
DB_HOST=localhost
PORT=3000
JWT_SECRET=your_jwt_secret_key
                

Note: In a real production environment, never commit your .env file to version control. Make sure to add it to your .gitignore file.

Creating User Model with Validations

Now, let's create our User model with built-in validations. Create a file in the models folder called User.js:

models/User.js


const { DataTypes } = require('sequelize');
const bcrypt = require('bcrypt');
const sequelize = require('../config/database');

const User = sequelize.define('User', {
    id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
    },
    username: {
        type: DataTypes.STRING,
        allowNull: false,
        unique: {
            msg: 'This username is already taken'
        },
        validate: {
            notEmpty: {
                msg: 'Username cannot be empty'
            },
            len: {
                args: [3, 30],
                msg: 'Username must be between 3 and 30 characters'
            }
        }
    },
    email: {
        type: DataTypes.STRING,
        allowNull: false,
        unique: {
            msg: 'This email is already registered'
        },
        validate: {
            isEmail: {
                msg: 'Please enter a valid email address'
            }
        }
    },
    password: {
        type: DataTypes.STRING,
        allowNull: false,
        validate: {
            notEmpty: {
                msg: 'Password cannot be empty'
            },
            len: {
                args: [6, 100],
                msg: 'Password must be at least 6 characters long'
            }
        }
    },
    role: {
        type: DataTypes.ENUM('user', 'admin'),
        defaultValue: 'user'
    }
}, {
    hooks: {
        beforeCreate: async (user) => {
            if (user.password) {
                const salt = await bcrypt.genSalt(10);
                user.password = await bcrypt.hash(user.password, salt);
            }
        },
        beforeUpdate: async (user) => {
            if (user.changed('password')) {
                const salt = await bcrypt.genSalt(10);
                user.password = await bcrypt.hash(user.password, salt);
            }
        }
    }
});

// Instance method to check password
User.prototype.validatePassword = async function(password) {
    return await bcrypt.compare(password, this.password);
};

module.exports = User;
                

Understanding Model Validations

In the code above, we've defined several types of validations:

  • Built-in validators: Like isEmail which checks if a value is a valid email.
  • Custom validators: Such as len which checks the length of a string.
  • Database constraints: Like unique which ensures no duplicates exist in the database.
  • Custom error messages: We've provided custom messages for each validation error to make debugging easier.

Think of validations as a series of quality checks your data must pass before it can be stored. Each validation is like a checkpoint, and data must clear all checkpoints to reach the database.

Understanding Model Hooks

Hooks are special functions that run during certain lifecycle events of a model. In our User model, we've used two hooks:

  • beforeCreate: This hook runs before a new user is created. We use it to hash the user's password.
  • beforeUpdate: This hook runs before an existing user is updated. We use it to hash the password if it's being changed.

Think of hooks as automatic triggers that fire at specific moments in your data's lifecycle - like how a security camera automatically starts recording when it detects motion.

Creating a Related Profile Model

Let's create a Profile model that will be associated with our User model. This will demonstrate how validations work across related models. Create a file in the models folder called Profile.js:

models/Profile.js


const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');

const Profile = sequelize.define('Profile', {
    id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
    },
    firstName: {
        type: DataTypes.STRING,
        validate: {
            len: {
                args: [2, 50],
                msg: 'First name must be between 2 and 50 characters'
            }
        }
    },
    lastName: {
        type: DataTypes.STRING,
        validate: {
            len: {
                args: [2, 50],
                msg: 'Last name must be between 2 and 50 characters'
            }
        }
    },
    bio: {
        type: DataTypes.TEXT,
        validate: {
            len: {
                args: [0, 500],
                msg: 'Bio cannot exceed 500 characters'
            }
        }
    },
    age: {
        type: DataTypes.INTEGER,
        validate: {
            isInt: {
                msg: 'Age must be a whole number'
            },
            min: {
                args: [13],
                msg: 'You must be at least 13 years old to register'
            },
            max: {
                args: [120],
                msg: 'Age must be less than 120'
            }
        }
    },
    phoneNumber: {
        type: DataTypes.STRING,
        validate: {
            is: {
                args: /^\+?[1-9]\d{9,14}$/,
                msg: 'Please enter a valid phone number'
            }
        }
    }
});

module.exports = Profile;
                

Now, let's define the associations between our models. Create a file called index.js in the models folder:

models/index.js


const User = require('./User');
const Profile = require('./Profile');

// Define associations
User.hasOne(Profile, {
    foreignKey: 'userId',
    as: 'profile',
    onDelete: 'CASCADE'
});

Profile.belongsTo(User, {
    foreignKey: 'userId',
    as: 'user'
});

module.exports = {
    User,
    Profile
};
                

Syncing the Database

Now that we have defined our models, let's create a file to sync these models with our database. Create a file in the root directory called sync.js:

sync.js


const sequelize = require('./config/database');
const { User, Profile } = require('./models');

async function syncDatabase() {
    try {
        // Sync all models with the database
        await sequelize.sync({ force: true });
        console.log('Database synchronized successfully!');
        
        process.exit(0);
    } catch (error) {
        console.error('Error synchronizing database:', error);
        process.exit(1);
    }
}

syncDatabase();
                

Warning: Using { force: true } will drop existing tables and recreate them. This is useful during development but should never be used in production.

Run this file to sync your models with the database:

node sync.js
                

Implementing Authentication

Now, let's implement authentication using JSON Web Tokens (JWT). First, let's create a controller for user authentication. Create a file in the controllers folder called authController.js:

controllers/authController.js


const jwt = require('jsonwebtoken');
const { User, Profile } = require('../models');

// Register a new user
exports.register = async (req, res) => {
    try {
        const { username, email, password, firstName, lastName, age, phoneNumber, bio } = req.body;
        
        // Create user (with validations)
        const user = await User.create({
            username,
            email,
            password
        });
        
        // Create associated profile (with validations)
        if (user) {
            await Profile.create({
                userId: user.id,
                firstName,
                lastName,
                age,
                phoneNumber,
                bio
            });
        }
        
        res.status(201).json({
            success: true,
            message: 'User registered successfully'
        });
    } catch (error) {
        // Handle validation errors
        if (error.name === 'SequelizeValidationError' || error.name === 'SequelizeUniqueConstraintError') {
            const errors = error.errors.map(err => ({
                field: err.path,
                message: err.message
            }));
            
            return res.status(400).json({
                success: false,
                errors
            });
        }
        
        console.error('Registration error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred during registration'
        });
    }
};

// Login user
exports.login = async (req, res) => {
    try {
        const { email, password } = req.body;
        
        // Find user by email
        const user = await User.findOne({ where: { email } });
        
        if (!user) {
            return res.status(401).json({
                success: false,
                message: 'Invalid email or password'
            });
        }
        
        // Validate password using the instance method
        const isValidPassword = await user.validatePassword(password);
        
        if (!isValidPassword) {
            return res.status(401).json({
                success: false,
                message: 'Invalid email or password'
            });
        }
        
        // Generate JWT token
        const token = jwt.sign(
            { id: user.id, username: user.username, role: user.role },
            process.env.JWT_SECRET,
            { expiresIn: '1h' }
        );
        
        res.status(200).json({
            success: true,
            token,
            user: {
                id: user.id,
                username: user.username,
                email: user.email,
                role: user.role
            }
        });
    } catch (error) {
        console.error('Login error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred during login'
        });
    }
};

// Get current user profile
exports.getMe = async (req, res) => {
    try {
        const user = await User.findByPk(req.user.id, {
            attributes: { exclude: ['password'] },
            include: [{ model: Profile, as: 'profile' }]
        });
        
        if (!user) {
            return res.status(404).json({
                success: false,
                message: 'User not found'
            });
        }
        
        res.status(200).json({
            success: true,
            data: user
        });
    } catch (error) {
        console.error('Get user profile error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while retrieving user profile'
        });
    }
};
                

Understanding the Authentication Flow

The authentication process works like a secure gateway to your application:

  1. Registration: A new user provides their credentials and personal information. This data is validated by Sequelize before being saved to the database. If validations fail, specific error messages are returned to guide the user.
  2. Login: An existing user provides their email and password. We find the user in the database and verify their password using bcrypt's compare function. If authentication succeeds, we issue a JWT token that serves as their digital identity card for subsequent requests.
  3. Protected Resources: For subsequent requests to protected endpoints, the user must present their JWT token. We'll create middleware to verify this token before allowing access to protected resources.

Think of this process as similar to how airport security works:

  • Registration is like applying for a passport - your information is carefully verified before a passport (account) is issued.
  • Login is like airport check-in - you present your passport and get a boarding pass (JWT token) that grants you access to specific areas.
  • Accessing protected resources is like using your boarding pass to enter the boarding area - the boarding pass is checked at the gate to ensure you have permission to enter.

Creating Authentication Middleware

Let's create a middleware to protect routes that require authentication. Create a folder called middleware in the root directory, and then create a file called auth.js inside it:

mkdir middleware
touch middleware/auth.js
                

middleware/auth.js


const jwt = require('jsonwebtoken');
const { User } = require('../models');

exports.protect = async (req, res, next) => {
    try {
        let token;
        
        // Check if token exists in headers
        if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
            token = req.headers.authorization.split(' ')[1];
        }
        
        if (!token) {
            return res.status(401).json({
                success: false,
                message: 'Not authorized to access this route'
            });
        }
        
        try {
            // Verify token
            const decoded = jwt.verify(token, process.env.JWT_SECRET);
            
            // Add user to request object
            req.user = decoded;
            
            next();
        } catch (error) {
            return res.status(401).json({
                success: false,
                message: 'Token is not valid or has expired'
            });
        }
    } catch (error) {
        console.error('Auth middleware error:', error);
        res.status(500).json({
            success: false,
            message: 'Server error in authentication'
        });
    }
};

// Middleware for role-based authorization
exports.authorize = (...roles) => {
    return (req, res, next) => {
        if (!roles.includes(req.user.role)) {
            return res.status(403).json({
                success: false,
                message: `User role ${req.user.role} is not authorized to access this route`
            });
        }
        next();
    };
};
                

Understanding Authentication Middleware

The authentication middleware acts like a security checkpoint that verifies the user's identity before allowing them to proceed to protected routes:

  1. It extracts the JWT token from the request headers.
  2. It verifies the token using the JWT secret.
  3. If the token is valid, it attaches the decoded user information to the request object, making it available to route handlers.
  4. If the token is invalid or missing, it denies access to the route.

Additionally, we've created a role-based authorization middleware that further restricts access based on user roles. This is like having different levels of security clearance in a building - some areas are accessible to all employees, while others are restricted to managers or executives.

Setting Up Routes

Now, let's create our routes. Create a file in the routes folder called authRoutes.js:

routes/authRoutes.js


const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const { protect } = require('../middleware/auth');

// Public routes
router.post('/register', authController.register);
router.post('/login', authController.login);

// Protected routes
router.get('/me', protect, authController.getMe);

module.exports = router;
                

Now, let's create an admin controller to demonstrate role-based authorization. Create a file in the controllers folder called adminController.js:

controllers/adminController.js


const { User, Profile } = require('../models');

// Get all users (admin only)
exports.getAllUsers = async (req, res) => {
    try {
        const users = await User.findAll({
            attributes: { exclude: ['password'] },
            include: [{ model: Profile, as: 'profile' }]
        });
        
        res.status(200).json({
            success: true,
            count: users.length,
            data: users
        });
    } catch (error) {
        console.error('Get all users error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while retrieving users'
        });
    }
};

// Update user role (admin only)
exports.updateUserRole = async (req, res) => {
    try {
        const { id } = req.params;
        const { role } = req.body;
        
        // Validate role
        if (!['user', 'admin'].includes(role)) {
            return res.status(400).json({
                success: false,
                message: 'Invalid role specified'
            });
        }
        
        const user = await User.findByPk(id);
        
        if (!user) {
            return res.status(404).json({
                success: false,
                message: 'User not found'
            });
        }
        
        // Update user role
        user.role = role;
        await user.save();
        
        res.status(200).json({
            success: true,
            message: 'User role updated successfully',
            data: {
                id: user.id,
                username: user.username,
                email: user.email,
                role: user.role
            }
        });
    } catch (error) {
        console.error('Update user role error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while updating user role'
        });
    }
};
                

Now, let's create routes for the admin controller. Create a file in the routes folder called adminRoutes.js:

routes/adminRoutes.js


const express = require('express');
const router = express.Router();
const adminController = require('../controllers/adminController');
const { protect, authorize } = require('../middleware/auth');

// Admin routes (protected and role-restricted)
router.get('/users', protect, authorize('admin'), adminController.getAllUsers);
router.put('/users/:id/role', protect, authorize('admin'), adminController.updateUserRole);

module.exports = router;
                

Creating a User Controller

Let's create a controller for regular user operations. Create a file in the controllers folder called userController.js:

controllers/userController.js


const { User, Profile } = require('../models');

// Update user profile
exports.updateProfile = async (req, res) => {
    try {
        const { firstName, lastName, bio, age, phoneNumber } = req.body;
        
        // Find user profile
        const profile = await Profile.findOne({ where: { userId: req.user.id } });
        
        if (!profile) {
            return res.status(404).json({
                success: false,
                message: 'Profile not found'
            });
        }
        
        // Update profile with validation
        await profile.update({
            firstName: firstName || profile.firstName,
            lastName: lastName || profile.lastName,
            bio: bio !== undefined ? bio : profile.bio,
            age: age || profile.age,
            phoneNumber: phoneNumber || profile.phoneNumber
        });
        
        res.status(200).json({
            success: true,
            message: 'Profile updated successfully',
            data: profile
        });
    } catch (error) {
        // Handle validation errors
        if (error.name === 'SequelizeValidationError') {
            const errors = error.errors.map(err => ({
                field: err.path,
                message: err.message
            }));
            
            return res.status(400).json({
                success: false,
                errors
            });
        }
        
        console.error('Update profile error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while updating profile'
        });
    }
};

// Change password
exports.changePassword = async (req, res) => {
    try {
        const { currentPassword, newPassword } = req.body;
        
        // Find user
        const user = await User.findByPk(req.user.id);
        
        if (!user) {
            return res.status(404).json({
                success: false,
                message: 'User not found'
            });
        }
        
        // Validate current password
        const isValidPassword = await user.validatePassword(currentPassword);
        
        if (!isValidPassword) {
            return res.status(401).json({
                success: false,
                message: 'Current password is incorrect'
            });
        }
        
        // Update password with validation
        user.password = newPassword;
        await user.save();
        
        res.status(200).json({
            success: true,
            message: 'Password changed successfully'
        });
    } catch (error) {
        // Handle validation errors
        if (error.name === 'SequelizeValidationError') {
            const errors = error.errors.map(err => ({
                field: err.path,
                message: err.message
            }));
            
            return res.status(400).json({
                success: false,
                errors
            });
        }
        
        console.error('Change password error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while changing password'
        });
    }
};
                

Now, let's create routes for the user controller. Create a file in the routes folder called userRoutes.js:

routes/userRoutes.js


const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { protect } = require('../middleware/auth');

// User routes (protected)
router.put('/profile', protect, userController.updateProfile);
router.put('/password', protect, userController.changePassword);

module.exports = router;
                

Setting Up the Express Application

Now, let's set up our Express application. Update the app.js file:

app.js


const express = require('express');
const sequelize = require('./config/database');
const authRoutes = require('./routes/authRoutes');
const userRoutes = require('./routes/userRoutes');
const adminRoutes = require('./routes/adminRoutes');
require('dotenv').config();

// Initialize Express app
const app = express();

// Middleware
app.use(express.json());

// Test database connection
sequelize.authenticate()
    .then(() => console.log('Database connection established successfully.'))
    .catch(err => console.error('Unable to connect to the database:', err));

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/admin', adminRoutes);

// Error handling middleware
app.use((err, req, res, next) => {
    console.error(err.stack);
    res.status(500).json({
        success: false,
        message: 'Something went wrong on the server'
    });
});

// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
});
                

Update your package.json to add scripts for running the application:

package.json (scripts section)


{
  "scripts": {
    "start": "node app.js",
    "dev": "nodemon app.js",
    "sync": "node sync.js"
  }
}
                

Testing Our Application

Now that we have built our application, let's test it using a tool like Postman or any API testing tool. Here are the endpoints we can test:

Authentication Endpoints

  • POST /api/auth/register - Register a new user
  • POST /api/auth/login - Login and get a token
  • GET /api/auth/me - Get current user profile (requires authentication)

User Endpoints

  • PUT /api/users/profile - Update user profile (requires authentication)
  • PUT /api/users/password - Change password (requires authentication)

Admin Endpoints

  • GET /api/admin/users - Get all users (requires admin role)
  • PUT /api/admin/users/:id/role - Update user role (requires admin role)

Step 1: Start the Application

First, let's sync our database and then start the application:

npm run sync
npm run dev
                    

Step 2: Register a User

Send a POST request to /api/auth/register with the following JSON body:


{
    "username": "testuser",
    "email": "test@example.com",
    "password": "password123",
    "firstName": "John",
    "lastName": "Doe",
    "age": 25,
    "phoneNumber": "+12345678900",
    "bio": "I am a test user"
}
                    

If all validations pass, you should receive a success message. If not, you'll get specific error messages explaining what went wrong.

Step 3: Login

Send a POST request to /api/auth/login with the following JSON body:


{
    "email": "test@example.com",
    "password": "password123"
}
                    

You should receive a JWT token in the response. Save this token for use in authenticated requests.

Step 4: Access Protected Endpoint

Send a GET request to /api/auth/me with the Authorization header set to Bearer YOUR_TOKEN.

You should receive your user profile information.

Step 5: Update Profile (Testing Validations)

Send a PUT request to /api/users/profile with an invalid age to test validations:


{
    "age": 10
}
                    

You should receive a validation error message about the age requirement.

Advanced Validation Techniques

Sequelize offers more advanced validation capabilities beyond what we've already covered. Let's explore some of these techniques by creating a new model for blog posts:

models/Post.js


const { DataTypes, Op } = require('sequelize');
const sequelize = require('../config/database');

const Post = sequelize.define('Post', {
    id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
    },
    title: {
        type: DataTypes.STRING,
        allowNull: false,
        validate: {
            notEmpty: {
                msg: 'Title cannot be empty'
            },
            len: {
                args: [3, 100],
                msg: 'Title must be between 3 and 100 characters'
            }
        }
    },
    content: {
        type: DataTypes.TEXT,
        allowNull: false,
        validate: {
            notEmpty: {
                msg: 'Content cannot be empty'
            },
            len: {
                args: [10, 5000],
                msg: 'Content must be between 10 and 5000 characters'
            }
        }
    },
    status: {
        type: DataTypes.ENUM('draft', 'published', 'archived'),
        defaultValue: 'draft',
        validate: {
            isIn: {
                args: [['draft', 'published', 'archived']],
                msg: 'Status must be draft, published, or archived'
            }
        }
    },
    tags: {
        type: DataTypes.STRING,
        get() {
            const rawValue = this.getDataValue('tags');
            return rawValue ? rawValue.split(',') : [];
        },
        set(val) {
            if (Array.isArray(val)) {
                this.setDataValue('tags', val.join(','));
            } else {
                this.setDataValue('tags', val);
            }
        },
        validate: {
            // Custom validator for tags
            tagsValidator(value) {
                if (value) {
                    const tags = Array.isArray(value) ? value : value.split(',');
                    
                    if (tags.length > 5) {
                        throw new Error('Maximum 5 tags allowed');
                    }
                    
                    tags.forEach(tag => {
                        if (tag.length > 20) {
                            throw new Error('Each tag must be less than 20 characters');
                        }
                    });
                }
            }
        }
    },
    publishDate: {
        type: DataTypes.DATE,
        validate: {
            isDate: true,
            // Custom validator to ensure published posts have a future publish date
            isPublishDateValid(value) {
                if (this.status === 'published' && (!value || new Date(value) <= new Date())) {
                    throw new Error('Publish date must be in the future for published posts');
                }
            }
        }
    },
    viewCount: {
        type: DataTypes.INTEGER,
        defaultValue: 0,
        validate: {
            isInt: true,
            min: 0
        }
    }
}, {
    // Model-level validations
    validate: {
        titleAndContentDifferent() {
            if (this.title === this.content) {
                throw new Error('Title and content cannot be the same');
            }
        },
        publishedPostRequiresContent() {
            if (this.status === 'published' && (!this.content || this.content.length < 100)) {
                throw new Error('Published posts must have at least 100 characters of content');
            }
        }
    },
    // Adding hooks to ensure status transitions are valid
    hooks: {
        beforeUpdate: async (post) => {
            // If trying to unpublish a post that has views
            if (post.changed('status') && 
                post.previous('status') === 'published' && 
                post.status === 'draft' && 
                post.viewCount > 0) {
                throw new Error('Cannot return a post with views to draft status');
            }
        }
    }
});

module.exports = Post;
                

Now, let's update our models/index.js file to include this association:

models/index.js (updated)


const User = require('./User');
const Profile = require('./Profile');
const Post = require('./Post');

// Define associations
User.hasOne(Profile, {
    foreignKey: 'userId',
    as: 'profile',
    onDelete: 'CASCADE'
});

Profile.belongsTo(User, {
    foreignKey: 'userId',
    as: 'user'
});

// Post associations
User.hasMany(Post, {
    foreignKey: 'authorId',
    as: 'posts'
});

Post.belongsTo(User, {
    foreignKey: 'authorId',
    as: 'author'
});

module.exports = {
    User,
    Profile,
    Post
};
                

Advanced Validation Techniques Explained

In the Post model, we've demonstrated several advanced validation techniques:

  1. Custom Validators: Functions like tagsValidator that implement complex validation logic.
  2. Cross-Field Validations: Model-level validations like titleAndContentDifferent that compare multiple fields.
  3. Conditional Validations: Checks like isPublishDateValid that apply rules conditionally based on other field values.
  4. Validation Hooks: Using lifecycle hooks like beforeUpdate to implement business rules that depend on the previous state.
  5. Getters and Setters: Custom methods that transform data when reading or writing to fields, like our tags field.

Think of these advanced validations as the difference between a basic security system that just locks the doors and an advanced one that also checks for unusual patterns, monitors environmental conditions, and adapts security levels based on different scenarios.

Real-World Examples and Best Practices

Example 1: E-commerce Product Validation

In an e-commerce application, product data integrity is crucial. Here's how you might validate a Product model:


const Product = sequelize.define('Product', {
    name: {
        type: DataTypes.STRING,
        allowNull: false,
        validate: {
            notEmpty: true,
            len: [3, 100]
        }
    },
    price: {
        type: DataTypes.DECIMAL(10, 2),
        allowNull: false,
        validate: {
            isDecimal: true,
            min: 0.01,
            max: 9999.99
        }
    },
    discount: {
        type: DataTypes.DECIMAL(5, 2),
        defaultValue: 0,
        validate: {
            isDecimal: true,
            min: 0,
            max: 100
        }
    },
    stockQuantity: {
        type: DataTypes.INTEGER,
        allowNull: false,
        validate: {
            isInt: true,
            min: 0
        }
    },
    sku: {
        type: DataTypes.STRING,
        allowNull: false,
        unique: true,
        validate: {
            is: /^[A-Z]{2}-[0-9]{4}-[A-Z]{2}$/,  // Format: XX-0000-XX
        }
    }
}, {
    validate: {
        discountedPriceCheck() {
            if (this.discount > 0) {
                const discountedPrice = this.price * (1 - this.discount / 100);
                if (discountedPrice < 0.01) {
                    throw new Error('Discounted price cannot be less than $0.01');
                }
            }
        }
    }
});
                    

Example 2: Event Registration System

For an event management system, validating event capacity and registration dates is essential:


const Event = sequelize.define('Event', {
    title: {
        type: DataTypes.STRING,
        allowNull: false,
        validate: {
            notEmpty: true
        }
    },
    startDate: {
        type: DataTypes.DATE,
        allowNull: false,
        validate: {
            isDate: true,
            isAfter: new Date().toISOString().split('T')[0] // Must be future date
        }
    },
    endDate: {
        type: DataTypes.DATE,
        allowNull: false,
        validate: {
            isDate: true
        }
    },
    capacity: {
        type: DataTypes.INTEGER,
        allowNull: false,
        validate: {
            isInt: true,
            min: 1,
            max: 1000
        }
    },
    registrationDeadline: {
        type: DataTypes.DATE,
        allowNull: false,
        validate: {
            isDate: true
        }
    }
}, {
    validate: {
        dateOrderCheck() {
            if (new Date(this.endDate) <= new Date(this.startDate)) {
                throw new Error('End date must be after start date');
            }
            if (new Date(this.registrationDeadline) >= new Date(this.startDate)) {
                throw new Error('Registration deadline must be before event start date');
            }
        }
    }
});
                    

Authentication and Validation Best Practices

  1. Layer Your Validations: Implement validations at multiple levels - client-side for immediate feedback, server-side for security, and database-level for data integrity.
  2. Provide Specific Error Messages: Error messages should guide users toward fixing the issue rather than just indicating something went wrong.
  3. Use Environment Variables for Secrets: Never hardcode JWT secrets or database credentials in your application code.
  4. Set Reasonable Token Expiration: Balance security (shorter timeouts) with user experience (longer sessions).
  5. Hash Passwords Before Storage: Always use a strong hashing algorithm like bcrypt for passwords.
  6. Implement Rate Limiting: Protect authentication endpoints from brute force attacks by limiting login attempts.
  7. Use HTTPS in Production: Always serve your API over HTTPS to prevent token interception.
  8. Validate Input Early: Check input data as early as possible in your request handling pipeline.
  9. Test Edge Cases: Explicitly test boundary conditions in your validations.
  10. Log Authentication Events: Maintain logs of login attempts, password changes, and permission changes for security auditing.

Practical Exercise: Building a Secure Note-Taking API

Let's apply what we've learned by creating a secure note-taking API with proper validations and authentication.

Step 1: Create a Note Model

Create a new file in the models folder called Note.js:


const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');

const Note = sequelize.define('Note', {
    id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
    },
    title: {
        type: DataTypes.STRING,
        allowNull: false,
        validate: {
            notEmpty: {
                msg: 'Title cannot be empty'
            },
            len: {
                args: [1, 100],
                msg: 'Title must be between 1 and 100 characters'
            }
        }
    },
    content: {
        type: DataTypes.TEXT,
        allowNull: false,
        validate: {
            notEmpty: {
                msg: 'Content cannot be empty'
            }
        }
    },
    isPrivate: {
        type: DataTypes.BOOLEAN,
        defaultValue: true
    },
    category: {
        type: DataTypes.STRING,
        validate: {
            len: {
                args: [0, 30],
                msg: 'Category must be less than 30 characters'
            }
        }
    }
});

module.exports = Note;
                    

Step 2: Update the Model Associations

Update the models/index.js file to include the Note model:


const User = require('./User');
const Profile = require('./Profile');
const Post = require('./Post');
const Note = require('./Note');

// Previous associations...

// Note associations
User.hasMany(Note, {
    foreignKey: 'userId',
    as: 'notes'
});

Note.belongsTo(User, {
    foreignKey: 'userId',
    as: 'user'
});

module.exports = {
    User,
    Profile,
    Post,
    Note
};
                    

Step 3: Create a Note Controller

Create a file in the controllers folder called noteController.js:


const { Note } = require('../models');

// Get all notes for the current user
exports.getNotes = async (req, res) => {
    try {
        const notes = await Note.findAll({
            where: { userId: req.user.id },
            order: [['createdAt', 'DESC']]
        });
        
        res.status(200).json({
            success: true,
            count: notes.length,
            data: notes
        });
    } catch (error) {
        console.error('Get notes error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while retrieving notes'
        });
    }
};

// Get a single note
exports.getNote = async (req, res) => {
    try {
        const note = await Note.findOne({
            where: {
                id: req.params.id,
                userId: req.user.id
            }
        });
        
        if (!note) {
            return res.status(404).json({
                success: false,
                message: 'Note not found or you do not have permission to view it'
            });
        }
        
        res.status(200).json({
            success: true,
            data: note
        });
    } catch (error) {
        console.error('Get note error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while retrieving the note'
        });
    }
};

// Create a new note
exports.createNote = async (req, res) => {
    try {
        const { title, content, isPrivate, category } = req.body;
        
        // Create note with validation
        const note = await Note.create({
            title,
            content,
            isPrivate: isPrivate !== undefined ? isPrivate : true,
            category,
            userId: req.user.id
        });
        
        res.status(201).json({
            success: true,
            message: 'Note created successfully',
            data: note
        });
    } catch (error) {
        // Handle validation errors
        if (error.name === 'SequelizeValidationError') {
            const errors = error.errors.map(err => ({
                field: err.path,
                message: err.message
            }));
            
            return res.status(400).json({
                success: false,
                errors
            });
        }
        
        console.error('Create note error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while creating the note'
        });
    }
};

// Update a note
exports.updateNote = async (req, res) => {
    try {
        const { title, content, isPrivate, category } = req.body;
        
        // Find the note
        const note = await Note.findOne({
            where: {
                id: req.params.id,
                userId: req.user.id
            }
        });
        
        if (!note) {
            return res.status(404).json({
                success: false,
                message: 'Note not found or you do not have permission to update it'
            });
        }
        
        // Update note with validation
        await note.update({
            title: title || note.title,
            content: content || note.content,
            isPrivate: isPrivate !== undefined ? isPrivate : note.isPrivate,
            category: category !== undefined ? category : note.category
        });
        
        res.status(200).json({
            success: true,
            message: 'Note updated successfully',
            data: note
        });
    } catch (error) {
        // Handle validation errors
        if (error.name === 'SequelizeValidationError') {
            const errors = error.errors.map(err => ({
                field: err.path,
                message: err.message
            }));
            
            return res.status(400).json({
                success: false,
                errors
            });
        }
        
        console.error('Update note error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while updating the note'
        });
    }
};

// Delete a note
exports.deleteNote = async (req, res) => {
    try {
        const result = await Note.destroy({
            where: {
                id: req.params.id,
                userId: req.user.id
            }
        });
        
        if (!result) {
            return res.status(404).json({
                success: false,
                message: 'Note not found or you do not have permission to delete it'
            });
        }
        
        res.status(200).json({
            success: true,
            message: 'Note deleted successfully'
        });
    } catch (error) {
        console.error('Delete note error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while deleting the note'
        });
    }
};

// Get all public notes
exports.getPublicNotes = async (req, res) => {
    try {
        const notes = await Note.findAll({
            where: { isPrivate: false },
            order: [['createdAt', 'DESC']]
        });
        
        res.status(200).json({
            success: true,
            count: notes.length,
            data: notes
        });
    } catch (error) {
        console.error('Get public notes error:', error);
        res.status(500).json({
            success: false,
            message: 'An error occurred while retrieving public notes'
        });
    }
};
                    

Step 4: Create Note Routes

Create a file in the routes folder called noteRoutes.js:


const express = require('express');
const router = express.Router();
const noteController = require('../controllers/noteController');
const { protect } = require('../middleware/auth');

// Public routes
router.get('/public', noteController.getPublicNotes);

// Protected routes
router.get('/', protect, noteController.getNotes);
router.get('/:id', protect, noteController.getNote);
router.post('/', protect, noteController.createNote);
router.put('/:id', protect, noteController.updateNote);
router.delete('/:id', protect, noteController.deleteNote);

module.exports = router;
                    

Step 5: Update the App.js File

Update your app.js file to include the new note routes:


// ... previous imports
const noteRoutes = require('./routes/noteRoutes');

// ... middleware and database connection

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/notes', noteRoutes);

// ... error handling and server start
                    

Step 6: Test the Note API

Now you can test the note API with Postman or any API testing tool:

  1. Create a note by sending a POST request to /api/notes with a valid JWT token.
  2. Retrieve all your notes with a GET request to /api/notes.
  3. Update a note by sending a PUT request to /api/notes/:id.
  4. Test validations by sending invalid data in your requests.

Further Learning and Resources

Official Documentation

Additional Topics to Explore

  • Two-Factor Authentication: Enhance security by requiring a second form of verification.
  • OAuth Integration: Allow users to sign in with social media accounts.
  • Refresh Tokens: Implement a more secure token rotation system.
  • Custom Validators: Create reusable validation functions across models.
  • Transaction Management: Ensure data integrity during complex operations.
  • SQL Injection Prevention: Learn how Sequelize helps prevent SQL injection attacks.

Conclusion

Congratulations! You've now built a robust API with Sequelize that implements both data validation and user authentication. Let's recap what we've learned:

  • Sequelize provides a powerful validation system to ensure data integrity.
  • Validations can be applied at the field level, model level, and through hooks.
  • Authentication with JWT provides a stateless way to secure API endpoints.
  • Role-based authorization allows for granular access control.
  • Combining validations with authentication creates a secure and reliable application.

Remember, validations and authentication are not just technical requirements but crucial components of a trustworthy application that respects user data and provides a reliable experience. Like a well-designed building, a well-designed application needs both a solid foundation (data integrity through validations) and secure doors (authentication) to be truly valuable.

As you continue your journey with Sequelize, remember to always consider how your data models, validations, and security measures work together to create a cohesive application architecture.