Color Mode

Building a User Registration System

In this assignment, you will create a user registration system that securely stores user accounts with hashed passwords. This builds directly on the contact form assignment, applying the same MVC patterns while introducing critical security concepts like password hashing and more sophisticated validation rules.

You will implement user registration with email and password confirmation, comprehensive form validation, and an administrative interface to view registered users. This assignment emphasizes security best practices by using bcrypt for password hashing and implementing robust validation on both client and server sides.

AI tools could complete this assignment for you in just a few minutes. Do not give in to that temptation. Many industries are already concerned that new developers, especially recent graduates and junior hires, struggle to code independently or think critically about their work. This assignment is designed to help you build the essential skills you will need as a developer. If you let AI do the work for you, you will miss out on learning critical skills and put your future career at risk.

Understanding Password Security

Storing user passwords requires special security considerations that static content never faces. Plain text passwords create serious security vulnerabilities: if your database is ever compromised, all user passwords are immediately exposed. Even having plain text passwords during development creates bad habits and opens the door for mistakes in production systems.

Password hashing solves this problem through one-way encryption. When a user registers, their password is processed through a hashing algorithm that creates a unique, irreversible string. The original password cannot be recovered from the hash, but the same password will always produce the same hash, allowing your application to verify login attempts.

The bcrypt library provides industry-standard password hashing that includes built-in salt generation and configurable difficulty levels. This assignment implements bcrypt from the beginning because it is much safer to start with proper password security than to retrofit it later into a system that was designed for plain text.

How bcrypt Works

When you call bcrypt.hash(password, 10), the library automatically generates a random salt, combines it with the password, and applies the hashing algorithm 2^10 (1,024) times. This process is intentionally slow to make brute force attacks impractical. The resulting hash contains both the salt and the final hash, so the same function can verify passwords later.

Salt is a random value added to the password before hashing to ensure that even identical passwords produce different hashes. This prevents attackers from using precomputed tables (rainbow tables) to reverse-engineer passwords from their hashes. No two users with the same password will have the same hash stored in the database.

Preparation

Before building the registration system, you need to create a database table to store user accounts and install the bcrypt library for password hashing. You will add this table to the same practice.sql file you created in the contact form assignment.

Open your existing src/models/sql/practice.sql file and add the following SQL at the end of the file, after your contact form table:


        -- Users table for registration system
        CREATE TABLE IF NOT EXISTS users (
            id SERIAL PRIMARY KEY,
            name VARCHAR(255) NOT NULL,
            email VARCHAR(255) UNIQUE NOT NULL,
            password VARCHAR(255) NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        );
    

Notice that you are adding to the same practice.sql file you created in the contact form assignment. This file is designed to accumulate database changes from multiple assignments throughout the course. Each time your database is re-seeded (when the faculty table is empty), both the contact form table and the users table will be created. This approach keeps your practice work organized and separate from the core course database structure.

Keep in mind in order to re-seed the database and run the practice SQL again, you need to delete all data from the faculty table. This triggers the seed process, which now includes your practice SQL file.

Next, install the bcrypt library for password hashing:


        pnpm install bcrypt
    

After installation, pnpm will display a warning about ignored build scripts for bcrypt. Run the following command to approve the build:


        pnpm approve-builds
    

When prompted, approve the bcrypt build. This allows the bcrypt native module to compile correctly for your system.

Restart your server and verify that the users table was created by checking your server console. You should see the message "Practice database tables initialized" after "Database seeded successfully". Use pgAdmin to examine your database tables and confirm that a new users table exists with columns for id, name, email, password, created_at, and updated_at. The email column should have a UNIQUE constraint to prevent duplicate accounts. Do not proceed until you confirm the table exists and your server starts without errors.

Assignment Instructions

1. Create Registration Styles

Create public/css/registration.css with comprehensive styling for the registration system:


        /* Registration Form Styles */
        .registration-form {
            max-width: 600px;
            margin: 2rem auto;
            padding: 2rem;
            border: 1px solid #ddd;
            border-radius: 8px;
            background-color: #fff;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);

            .form-group {
                margin-bottom: 1.5rem;

                label {
                    display: block;
                    margin-bottom: 0.5rem;
                    font-weight: 500;
                    color: #333;
                }

                input {
                    width: 100%;
                    padding: 0.75rem;
                    border: 1px solid #ddd;
                    border-radius: 6px;
                    font-size: 1rem;
                    font-family: inherit;
                    box-sizing: border-box;
                    transition: border-color 0.2s ease, box-shadow 0.2s ease;

                    &:focus {
                        outline: none;
                        border-color: #2c5aa0;
                        box-shadow: 0 0 5px rgba(44, 90, 160, 0.3);
                    }

                    &:invalid {
                        border-color: #dc3545;
                    }

                    &:valid {
                        border-color: #28a745;
                    }
                }

                .help-text {
                    font-size: 0.9rem;
                    color: #666;
                    margin-top: 0.25rem;
                }
            }

            button[type="submit"] {
                width: 100%;
                padding: 1rem;
                background-color: #2c5aa0;
                color: white;
                border: none;
                border-radius: 6px;
                font-size: 1rem;
                font-weight: 500;
                cursor: pointer;
                transition: background-color 0.3s ease;

                &:hover {
                    background-color: #1e3a6f;
                }

                &:disabled {
                    background-color: #ccc;
                    cursor: not-allowed;
                }
            }
        }

        /* Users List Styles */
        .users-list {
            max-width: 900px;
            margin: 2rem auto;
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
            gap: 1.5rem;

            .user-card {
                background-color: #fff;
                border: 1px solid #ddd;
                border-radius: 8px;
                padding: 1.5rem;
                box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
                transition: box-shadow 0.2s ease;

                &:hover {
                    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
                }

                .user-header {
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    margin-bottom: 1rem;
                    padding-bottom: 1rem;
                    border-bottom: 2px solid #2c5aa0;

                    h3 {
                        margin: 0;
                        color: #2c5aa0;
                        font-size: 1.25rem;
                    }

                    .user-id {
                        background-color: #e9ecef;
                        padding: 0.25rem 0.75rem;
                        border-radius: 12px;
                        font-size: 0.875rem;
                        color: #666;
                        font-weight: 500;
                    }
                }

                .user-details {
                    .detail-item {
                        margin-bottom: 0.75rem;

                        &:last-child {
                            margin-bottom: 0;
                        }

                        .label {
                            font-weight: 500;
                            color: #666;
                            font-size: 0.875rem;
                            margin-bottom: 0.25rem;
                        }

                        .value {
                            color: #333;
                            font-size: 1rem;
                        }
                    }
                }
            }

            .no-users {
                grid-column: 1 / -1;
                text-align: center;
                padding: 3rem;
                color: #666;
                font-style: italic;
            }
        }
    

2. Set Up Dynamic CSS Loading

Add middleware to your routes configuration to load the registration CSS. Open src/controllers/routes.js and add the following middleware with your other route-specific middleware:


        // Add registration-specific styles to all registration routes
        router.use('/register', (req, res, next) => {
            res.addStyle('<link rel="stylesheet" href="/css/registration.css">');
            next();
        });
    

This follows the same pattern you used in the contact form assignment, ensuring the registration CSS loads for all routes under /register.

3. Create the Registration Model

Create the model file that handles database operations for user registration. Create src/models/forms/registration.js with the following content:


        import db from '../db.js';

        /**
         * Checks if an email address is already registered in the database.
         * 
         * @param {string} email - The email address to check
         * @returns {Promise<boolean>} True if email exists, false otherwise
         */
        const emailExists = async (email) => {
            const query = `
                SELECT EXISTS(SELECT 1 FROM users WHERE email = $1) as exists
            `;
            const result = await db.query(query, [email]);
            return result.rows[0].exists;
        };

        /**
         * Saves a new user to the database with a hashed password.
         * 
         * @param {string} name - The user's full name
         * @param {string} email - The user's email address
         * @param {string} hashedPassword - The bcrypt-hashed password
         * @returns {Promise<Object>} The newly created user record (without password)
         */
        const saveUser = async (name, email, hashedPassword) => {
            const query = `
                INSERT INTO users (name, email, password)
                VALUES ($1, $2, $3)
                RETURNING id, name, email, created_at
            `;
            const result = await db.query(query, [name, email, hashedPassword]);
            return result.rows[0];
        };

        /**
         * Retrieves all registered users from the database.
         * 
         * @returns {Promise<Array>} Array of user records (without passwords)
         */
        const getAllUsers = async () => {
            const query = `
                SELECT id, name, email, created_at
                FROM users
                ORDER BY created_at DESC
            `;
            const result = await db.query(query);
            return result.rows;
        };

        export { emailExists, saveUser, getAllUsers };
    
Security Note: Never Return Passwords

Notice that none of the model functions return the password field, even though it is stored as a hash. This is a security best practice: passwords (even hashed ones) should never be included in query results unless absolutely necessary for authentication. The RETURNING clause and SELECT statements explicitly list only the fields that should be returned, omitting the password field entirely.

4. Understanding bcrypt Password Hashing

Before implementing the controller, you need to understand how to use bcrypt for password hashing. The bcrypt library provides two main functions: hash() for creating password hashes and compare() for verifying passwords (which you will use in a future login assignment).

To hash a password, you call bcrypt.hash(password, saltRounds). The salt rounds parameter (typically 10) determines how many times the hashing algorithm runs. Higher numbers are more secure but slower. The function returns a promise that resolves to the hashed password string.

Here is an example of basic usage:


        import bcrypt from 'bcrypt';

        // Hash a password with 10 salt rounds
        const password = 'userPassword123!';
        const hashedPassword = await bcrypt.hash(password, 10);
        
        // The hash looks like: $2b$10$N9qo8uLOickgx2ZMRZoMye...
        console.log(hashedPassword);
    

In your registration controller, you will use this pattern to hash passwords before saving them to the database. The hashing happens in the controller (not the model) because it is part of the business logic for processing registration, while the model focuses purely on database operations.

You might wonder why we hash passwords in the controller rather than the model. This separation follows MVC principles: the model should handle database operations with whatever data it receives, while the controller handles the business logic of preparing that data. This makes the model more reusable (it could save users from different sources, not just registration) and keeps security transformations explicit in the controller where they are easy to audit and maintain.

5. Create the Registration Controller

Create the controller file for your registration functionality. Create src/controllers/forms/registration.js. This file will contain your controller functions, validation rules, and route definitions all together, following the same pattern you used in the contact form assignment.

Start by adding the imports and the validation rules array. This part is provided complete because validation rules require specific syntax:


        import { Router } from 'express';
        import { body, validationResult } from 'express-validator';
        import bcrypt from 'bcrypt';
        import { emailExists, saveUser, getAllUsers } from '../../models/forms/registration.js';

        const router = Router();

        /**
         * Validation rules for user registration
         */
        const registrationValidation = [
            body('name')
                .trim()
                .isLength({ min: 2 })
                .withMessage('Name must be at least 2 characters'),
            body('email')
                .trim()
                .isEmail()
                .normalizeEmail()
                .withMessage('Must be a valid email address'),
            body('emailConfirm')
                .trim()
                .custom((value, { req }) => value === req.body.email)
                .withMessage('Email addresses must match'),
            body('password')
                .isLength({ min: 8 })
                .matches(/[0-9]/)
                .withMessage('Password must contain at least one number')
                .matches(/[!@#$%^&*]/)
                .withMessage('Password must contain at least one special character'),
            body('passwordConfirm')
                .custom((value, { req }) => value === req.body.password)
                .withMessage('Passwords must match')
        ];
    

Now add the controller functions. These are scaffolded with TODO comments and structural guidance to help you implement them. Follow the patterns you learned in the contact form assignment:


        /**
         * Display the registration form page.
         */
        const showRegistrationForm = (req, res) => {
            // TODO: Render the registration form view (forms/registration/form)
            // TODO: Pass title: 'User Registration' in the data object
        };

        /**
         * Handle user registration with validation and password hashing.
         */
        const processRegistration = async (req, res) => {
            // Check for validation errors
            const errors = validationResult(req);
            
            if (!errors.isEmpty()) {
                // TODO: Log validation errors to console for debugging
                // TODO: Redirect back to /register
                return;
            }

            // Extract validated data from request body
            // TODO: Destructure name, email, password from req.body
            
            try {
                // Check if email already exists in database
                // TODO: Call emailExists(email) and store the result in a variable
                
                if (/* TODO: check if email exists */) {
                    // TODO: Log message: 'Email already registered'
                    // TODO: Redirect back to /register
                    return;
                }
                
                // Hash the password before saving to database
                // TODO: Use bcrypt.hash(password, 10) to hash the password
                // TODO: Store the result in a variable called hashedPassword
                
                // Save user to database with hashed password
                // TODO: Call saveUser(name, email, hashedPassword)
                
                // TODO: Log success message to console
                // TODO: Redirect to /register/list to show successful registration
                // NOTE: Later when we add authentication, we'll change this to require login first
            } catch (error) {
                // TODO: Log the error to console
                // TODO: Redirect back to /register
            }
        };

        /**
         * Display all registered users.
         */
        const showAllUsers = async (req, res) => {
            // Initialize users as empty array
            let users = [];
            
            try {
                // TODO: Call getAllUsers() and assign to users variable
            } catch (error) {
                // TODO: Log the error to console
                // users remains empty array on error
            }
            
            // TODO: Render the users list view (forms/registration/list)
            // TODO: Pass title: 'Registered Users' and the users variable in the data object
        };
    

Finally, add the route definitions at the end of the file:


        /**
         * GET /register - Display the registration form
         */
        router.get('/', showRegistrationForm);

        /**
         * POST /register - Handle registration form submission with validation
         */
        router.post('/', registrationValidation, processRegistration);

        /**
         * GET /register/list - Display all registered users
         */
        router.get('/list', showAllUsers);

        export default router;
    

Now integrate these routes into your main routes file. Open src/controllers/routes.js and import the registration routes at the top with your other imports:


        import registrationRoutes from './forms/registration.js';
    

Then add the registration routes to your router after your other route definitions:


        // Registration routes
        router.use('/register', registrationRoutes);
    

This matches the same pattern as your contact form: the controller uses relative paths (/ and /list), and they get mounted under the /register prefix in the main routes file. The final URLs will be /register for the form, /register for the POST, and /register/list for the users list.

Complete each TODO in the controller functions by following the patterns from your contact form assignment. The structure is provided to guide you, but you need to write the actual implementation. Pay special attention to the password hashing step, as this is new functionality you have not implemented before. Make sure to use await when calling async functions like bcrypt.hash(), emailExists(), and saveUser().

6. Create Registration Views

Build the user interface for registration. Create the directory src/views/forms/registration/ and create form.ejs in that directory. The structure is outlined with comments to guide you:


        <%- include('../../partials/header') %>
        <main>
            <h1><%= title %></h1>
            <p>
                Register for a new account by providing your information below. All fields are required and passwords must meet security requirements.
            </p>
            
            <form method="POST" action="/register" class="registration-form">
                <!-- Name field -->
                <div class="form-group">
                    <!-- TODO: Add label for="name" -->
                    <!-- TODO: Add input type="text" id="name" name="name" -->
                    <!-- TODO: Add minlength="2" and required attributes -->
                    <!-- TODO: Add appropriate placeholder text -->
                </div>
                
                <!-- Email field -->
                <!-- TODO: Create form-group div -->
                <!-- TODO: Add label for email -->
                <!-- TODO: Add input type="email" with id, name, and required -->
                
                <!-- Email confirmation field -->
                <!-- TODO: Create form-group div for emailConfirm -->
                <!-- TODO: Should match email field structure -->
                <!-- TODO: Use name="emailConfirm" -->
                
                <!-- Password field -->
                <!-- TODO: Create form-group div -->
                <!-- TODO: Add label for password -->
                <!-- TODO: Add input type="password" -->
                <!-- TODO: Add minlength="8" and required attributes -->
                <!-- TODO: Add pattern="(?=.*[0-9])(?=.*[!@#$%^&*]).*" for validation -->
                <!-- TODO: Add a div with class="help-text" explaining requirements -->
                <!-- HINT: "Must be at least 8 characters with a number and special character" -->
                
                <!-- Password confirmation field -->
                <!-- TODO: Similar to password field but for passwordConfirm -->
                <!-- TODO: Use name="passwordConfirm" and appropriate label -->
                
                <!-- Submit button -->
                <!-- TODO: Add button type="submit" with text like "Create Account" -->
            </form>
        </main>
        <%- include('../../partials/footer') %>
    

Create the form following the same structure as your contact form, using the appropriate input types and validation attributes. The password fields should use type="password" and include the pattern attribute for client-side validation.

Now create src/views/forms/registration/list.ejs to display all registered users. This file uses a more structured TODO format with hints about the HTML structure:


        <%- include('../../partials/header') %>
        <main>
            <h1><%= title %></h1>
            
            <!-- TODO: Check if users array exists and has content using: if (users && users.length > 0) -->
            
                <div class="users-list">
                    <!-- TODO: Loop through users array with forEach -->
                    <!-- For each user in the loop, create this structure: -->
                    
                        <div class="user-card">
                            <div class="user-header">
                                <!-- TODO: Add h3 with user's name -->
                                <!-- TODO: Add span with class="user-id" showing: ID: user.id -->
                            </div>
                            
                            <div class="user-details">
                                <div class="detail-item">
                                    <!-- TODO: Add div with class="label" containing: Email -->
                                    <!-- TODO: Add div with class="value" containing: user.email -->
                                </div>
                                
                                <div class="detail-item">
                                    <!-- TODO: Add div with class="label" containing: Registered -->
                                    <!-- TODO: Add div with class="value" containing formatted date -->
                                    <!-- HINT: Use new Date(user.created_at).toLocaleDateString() -->
                                </div>
                            </div>
                        </div>
                    
                    <!-- TODO: End the forEach loop -->
                </div>
            
            <!-- TODO: Add else clause -->
                <!-- TODO: Add paragraph with class="no-users" -->
                <!-- TODO: Display message: "No users registered yet." -->
            <!-- TODO: Close the if/else block -->
            
        </main>
        <%- include('../../partials/footer') %>
    

Follow the same pattern as your contact form responses view, but adapt it to display user information with the specific CSS classes shown in the registration styles. The structure uses a card-based layout with user header and details sections.

To make your registration system easier to access during development and testing, add navigation links to your header partial. Open src/views/partials/header.ejs and locate your existing navigation list. Add the following list items inside your <nav><ul> structure, alongside your other navigation links:


        <li><a href="/register">Register</a></li>
        <li><a href="/register/list">Users</a></li>
    

The first link provides access to the registration form, while the second link allows you to view all registered users. In a production application, you would typically protect the users list page with authentication so that only administrators can view registered users. For now, leaving it accessible makes testing and development more convenient.

7. Test Your Registration System

Thoroughly test your registration system to ensure all functionality works correctly:

  1. Visit http://127.0.0.1:3000/register to see the registration form.
  2. Test client-side validation by trying to submit the form with invalid data (short name, invalid email format, weak password). The browser should prevent submission and show appropriate error messages.
  3. Test server-side validation by bypassing client-side validation. You can use the browser's developer tools to add novalidate to the form's opening tag or run the following JavaScript to automatically add it to all forms on the page:
    
                    document.querySelectorAll('form').forEach((form) => {
                        form.setAttribute('novalidate', '');
                    });
                
    Submit invalid data and verify the server redirects back without saving anything.
  4. Test server-side validation by submitting the form with mismatched emails and passwords. The server should prevent submission and show appropriate error messages.
  5. Register a valid user with all fields properly filled out. After submission, you should be automatically redirected to the users list page where you can see your new registration.
  6. Use pgAdmin to verify that the password is stored as a bcrypt hash (it should start with $2b$ and look like random characters, not the plain text password you entered).
  7. Try registering with the same email address again. The system should prevent the duplicate registration and log an appropriate message to your server console.
  8. Visit http://127.0.0.1:3000/register/list directly to verify you can access the users list page.

Check your server console throughout testing to verify that appropriate messages are being logged for successful registrations, validation failures, and duplicate email attempts. The console output helps you debug issues and confirms that your error handling is working correctly.

When implementing security features, it is crucial to test various scenarios, including edge cases. Try submitting the form with missing fields, invalid formats, and duplicate emails to ensure your validation and error handling are robust. Always verify that sensitive data like passwords are never exposed in logs or error messages. Always verify that the passwords are actually hashed in the database by checking in pgAdmin. If you see plain text passwords, your bcrypt hashing is not working correctly.

Key Concepts Summary

This assignment introduced critical security concepts for web applications. Password hashing with bcrypt ensures that user credentials remain secure even if your database is compromised. The one-way nature of cryptographic hashing means that original passwords cannot be recovered, only verified.

The comprehensive validation system you implemented protects against common security vulnerabilities and improves user experience. Server-side validation ensures data integrity and security, while client-side validation provides immediate feedback to users. Email and password confirmation fields help prevent user errors during registration.

The email uniqueness check prevents duplicate accounts and maintains data integrity. By implementing this check at both the database level (UNIQUE constraint) and the application level (emailExists function), you ensure that no duplicate emails can be stored regardless of how the registration function is called.

Your MVC architecture now includes user management functionality that follows the same patterns as your contact form. This consistency makes your codebase more maintainable and demonstrates how the MVC pattern scales to handle different types of data and user interactions. The single-file controller approach keeps related functionality organized and easy to maintain.

Experiment and Extend

Try adding additional user fields like phone number or address, remembering to update your database table in practice.sql using ALTER TABLE statements, add validation rules for the new fields, update your model functions to handle the additional columns, and modify your views to display the additional information.

Experiment with different bcrypt salt rounds to understand the performance implications of stronger hashing. Try changing the 10 in bcrypt.hash(password, 10) to 12 or 14 and notice how much slower the hashing becomes. This demonstrates the security tradeoff between protection and performance.

Consider adding user profile pages that display individual user information, or implement user search and filtering functionality on the users list page. You could also add pagination to handle large numbers of registered users efficiently, or add a delete button that allows removing test users from the database.