Understanding the Problem
In this phase, we need to implement user authentication in our Express application. This involves several key components:
- Setting up a Users database table to store user information
- Creating a User model with proper validations
- Implementing password hashing for security
- Creating authentication middleware using JSON Web Tokens (JWT)
- Testing the authentication functionality
User authentication is a critical component of almost any web application. It allows us to identify users, protect sensitive routes and information, and provide personalized experiences based on user identity.
Planning the Solution
- Set up Git feature branching for this phase
- Install bcryptjs for password hashing
- Create a Users database table migration
- Create the User model with validations
- Create seed data for demo users
- Add model scoping to protect sensitive user information
- Create authentication utility functions
- Test the authentication functionality
Implementing the Solution
1. Set Up Git Feature Branch
First, let's create a new feature branch for our authentication implementation. This helps isolate the changes and makes it easier to manage the development process.
git branch dev
git checkout dev
git checkout -b auth-setup
2. Install bcryptjs
We'll use bcryptjs to hash passwords securely. Install it with npm:
cd backend
npm install bcryptjs
3. Create Users Table Migration
Now, let's create a migration file for the Users table. We'll use Sequelize CLI to generate the initial files:
npx sequelize model:generate --name User --attributes username:string,email:string,hashedPassword:string
This command creates two files:
- A migration file in
backend/db/migrations - A model file in
backend/db/models
Let's modify the migration file to add constraints according to our schema:
// backend/db/migrations/XXXXXXXXXXXXXX-create-user.js
'use strict';
let options = {};
if (process.env.NODE_ENV === 'production') {
options.schema = process.env.SCHEMA; // define your schema in options object
}
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('Users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
username: {
type: Sequelize.STRING(30),
allowNull: false,
unique: true
},
email: {
type: Sequelize.STRING(256),
allowNull: false,
unique: true
},
hashedPassword: {
type: Sequelize.STRING.BINARY,
allowNull: false
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
}, options);
},
async down(queryInterface, Sequelize) {
options.tableName = "Users";
return queryInterface.dropTable(options);
}
};
Notice the key elements in this migration:
- We define length constraints for username (30 characters) and email (256 characters)
- We set unique constraints for both username and email
- We use BINARY string type for the hashedPassword for better security
- We set default values for createdAt and updatedAt timestamps
- We handle the schema option for production PostgreSQL database
Now, run the migration to create the Users table:
npx dotenv sequelize db:migrate
You can verify the table was created correctly using SQLite3:
sqlite3 db/dev.db ".schema Users"
4. Enhance the User Model with Validations
Next, let's update the User model file to add Sequelize model-level validations:
// backend/db/models/user.js
'use strict';
const { Model, Validator } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
static associate(models) {
// define association here
}
}
User.init(
{
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
len: [4, 30],
isNotEmail(value) {
if (Validator.isEmail(value)) {
throw new Error('Cannot be an email.');
}
},
},
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
len: [3, 256],
isEmail: true,
},
},
hashedPassword: {
type: DataTypes.STRING.BINARY,
allowNull: false,
validate: {
len: [60, 60],
},
},
},
{
sequelize,
modelName: 'User',
}
);
return User;
};
The model-level validations provide an additional layer of data integrity:
- Username must be between 4 and 30 characters and cannot be an email
- Email must be between 3 and 256 characters and must be a valid email format
- HashedPassword must be exactly 60 characters (the standard output length of bcrypt)
The isNotEmail validator is a custom validator that prevents users from using emails as usernames, ensuring a clear distinction between these fields.
5. Create User Seeds
Let's create seed data to populate our Users table with some initial users. First, generate a seeder file:
npx sequelize seed:generate --name demo-user
Now, edit the seeder file to create demo users:
// backend/db/seeders/XXXXXXXXXXXXXX-demo-user.js
'use strict';
const { User } = require('../models');
const bcrypt = require("bcryptjs");
let options = {};
if (process.env.NODE_ENV === 'production') {
options.schema = process.env.SCHEMA; // define your schema in options object
}
module.exports = {
async up (queryInterface, Sequelize) {
await User.bulkCreate([
{
email: 'demo@user.io',
username: 'Demo-lition',
hashedPassword: bcrypt.hashSync('password')
},
{
email: 'user1@user.io',
username: 'FakeUser1',
hashedPassword: bcrypt.hashSync('password2')
},
{
email: 'user2@user.io',
username: 'FakeUser2',
hashedPassword: bcrypt.hashSync('password3')
}
], { validate: true });
},
async down (queryInterface, Sequelize) {
options.tableName = 'Users';
const Op = Sequelize.Op;
return queryInterface.bulkDelete(options, {
username: { [Op.in]: ['Demo-lition', 'FakeUser1', 'FakeUser2'] }
}, {});
}
};
In this seeder, we:
- Use bcrypt to hash the passwords for security
- Create three demo users with different credentials
- Include the { validate: true } option to ensure our model validations are applied during seeding
- Include logic to delete these users in the down function
Now, run the seeder to populate the database:
npx dotenv sequelize db:seed:all
Verify the users were created:
sqlite3 db/dev.db 'SELECT * FROM "Users"'
6. Add Model Scopes for User Data Protection
To protect sensitive user information like hashedPassword from being exposed, let's add a defaultScope to the User model:
// backend/db/models/user.js
// ... existing code
User.init(
{
// ... existing attributes
},
{
sequelize,
modelName: 'User',
defaultScope: {
attributes: {
exclude: ['hashedPassword', 'email', 'createdAt', 'updatedAt'],
},
},
}
);
The defaultScope ensures that when we query for users, sensitive fields like hashedPassword are automatically excluded from the results. This is an important security measure to prevent accidental exposure of sensitive data.
7. Create Authentication Utilities
Now, let's create utility functions for authentication. First, create a utils folder and auth.js file:
mkdir -p backend/utils
touch backend/utils/auth.js
Add the following code to auth.js:
// backend/utils/auth.js
const jwt = require('jsonwebtoken');
const { jwtConfig } = require('../config');
const { User } = require('../db/models');
const { secret, expiresIn } = jwtConfig;
// Sends a JWT Cookie
const setTokenCookie = (res, user) => {
// Create the token.
const safeUser = {
id: user.id,
email: user.email,
username: user.username,
};
const token = jwt.sign(
{ data: safeUser },
secret,
{ expiresIn: parseInt(expiresIn) } // 604,800 seconds = 1 week
);
const isProduction = process.env.NODE_ENV === "production";
// Set the token cookie
res.cookie('token', token, {
maxAge: expiresIn * 1000, // maxAge in milliseconds
httpOnly: true,
secure: isProduction,
sameSite: isProduction && "Lax"
});
return token;
};
const restoreUser = (req, res, next) => {
// token parsed from cookies
const { token } = req.cookies;
req.user = null;
return jwt.verify(token, secret, null, async (err, jwtPayload) => {
if (err) {
return next();
}
try {
const { id } = jwtPayload.data;
req.user = await User.findByPk(id, {
attributes: {
include: ['email', 'createdAt', 'updatedAt']
}
});
} catch (e) {
res.clearCookie('token');
return next();
}
if (!req.user) res.clearCookie('token');
return next();
});
};
// If there is no current user, return an error
const requireAuth = function (req, _res, next) {
if (req.user) return next();
const err = new Error('Authentication required');
err.title = 'Authentication required';
err.errors = { message: 'Authentication required' };
err.status = 401;
return next(err);
}
module.exports = { setTokenCookie, restoreUser, requireAuth };
This file implements three key authentication utilities:
- setTokenCookie: Creates a JWT containing the user's information (without sensitive data) and sets it as an HTTP-only cookie.
- restoreUser: Middleware that verifies the JWT in the request cookies and loads the corresponding user into req.user.
- requireAuth: Middleware that checks if a user is authenticated (req.user exists) and returns an error if not.
8. Test Authentication Utilities
Let's add some test routes to verify our authentication utilities are working correctly. Add these to your backend/routes/api/index.js file:
// backend/routes/api/index.js
// ... existing code
// GET /api/set-token-cookie
const { setTokenCookie } = require('../../utils/auth.js');
const { User } = require('../../db/models');
router.get('/set-token-cookie', async (_req, res) => {
const user = await User.findOne({
where: {
username: 'Demo-lition'
}
});
setTokenCookie(res, user);
return res.json({ user: user });
});
// GET /api/restore-user
const { restoreUser } = require('../../utils/auth.js');
router.use(restoreUser);
router.get(
'/restore-user',
(req, res) => {
return res.json(req.user);
}
);
// GET /api/require-auth
const { requireAuth } = require('../../utils/auth.js');
router.get(
'/require-auth',
requireAuth,
(req, res) => {
return res.json(req.user);
}
);
module.exports = router;
These test routes allow us to verify each authentication utility:
/api/set-token-cookie: Sets a token cookie for the demo user/api/restore-user: Shows the current user based on the token cookie/api/require-auth: Tests the requireAuth middleware
Start your server and test each route:
npm start
- First, navigate to http://localhost:8000/api/set-token-cookie to set a token cookie for the demo user.
- Then, visit http://localhost:8000/api/restore-user to see if the user is restored from the cookie.
- Finally, visit http://localhost:8000/api/require-auth to test the requireAuth middleware. You should see the user information if authenticated, or an "Authentication required" error if not.
After testing, you can remove these test routes, but make sure to keep the router.use(restoreUser) line, as it will be needed for all API routes.
// backend/routes/api/index.js
const router = require("express").Router();
const { restoreUser } = require("../../utils/auth.js");
// Connect restoreUser middleware to the API router
// If current user session is valid, set req.user to the user in the database
// If current user session is not valid, set req.user to null
router.use(restoreUser);
module.exports = router;
Review the Solution
Let's review what we've accomplished in this phase:
- Created a Users database table with appropriate constraints
- Implemented a User model with comprehensive validations
- Added seed data for testing purposes
- Implemented model scoping to protect sensitive user information
- Created authentication utilities using JWT
- Successfully tested these utilities
Understanding the Authentication Flow
The authentication system we've built follows this general flow:
- User Registration: The application creates a new user with a hashed password.
- User Login: The application verifies credentials, then creates a JWT containing non-sensitive user information and sets it as an HTTP-only cookie.
- Authentication: On subsequent requests, the application reads the JWT cookie, verifies it, and loads the corresponding user.
- Protected Routes: Certain routes use the requireAuth middleware to ensure only authenticated users can access them.
- User Logout: The application clears the JWT cookie to end the session.
Security Considerations
Our implementation includes several important security practices:
- Password Hashing: We never store plain-text passwords, only bcrypt hashes.
- HTTP-Only Cookies: The JWT is stored in an HTTP-only cookie, protecting it from JavaScript access and XSS attacks.
- CSRF Protection: We use the csurf middleware to prevent CSRF attacks.
- Model Scoping: Sensitive information is automatically excluded from queries unless explicitly included.
- Validation: Comprehensive validation at both the database and model level prevents invalid data.
Real-world Application
The authentication system we've built is similar to those used in real-world applications. For example, many popular web services use JWT-based authentication for stateless API authentication.
Companies like Airbnb, Uber, and countless others implement similar authentication flows, with the specifics varying based on their security requirements. For instance, some applications might use shorter JWT expiration times and implement refresh tokens for enhanced security.
Common Issues and Solutions
- JWT Not Being Set: Check that the cookie settings (especially secure and sameSite) are appropriate for your environment.
- User Not Being Restored: Ensure that the JWT secret matches between token creation and verification, and that the User model is correctly imported.
- Require Auth Always Failing: Verify that the restoreUser middleware is correctly connected before the requireAuth middleware.
- Validation Errors During Seeding: Check that your seed data meets all model validation requirements.
Next Steps
Now that we have set up the core authentication functionality, we need to create the actual API routes that users will interact with:
- Login: To authenticate users with credentials
- Logout: To end a user's session
- Signup: To create new user accounts
- Get session user: To retrieve the current authenticated user
These will be implemented in Phase 4: User Authentication Routes.
Commit Your Code
Before moving on, commit your changes to the feature branch:
git add .
git commit -m "Add Users table migration and authentication utilities"
git checkout dev
git merge auth-setup
git push origin dev