Understanding the Problem
In this phase, we need to create the authentication API routes for our Express application. These routes will allow users to:
- Log in to the application
- Log out of the application
- Sign up for a new account
- Retrieve information about the current session user
These routes form the foundation of user authentication in any web application. They allow users to establish and manage their identity within your system, which is crucial for personalized experiences and data security.
Planning the Solution
We will create the following API routes:
POST /api/session- Log in a userDELETE /api/session- Log out a userPOST /api/users- Sign up a new userGET /api/session- Get the current session user
For each route, we will follow a feature-branch Git workflow to keep our development process organized and allow for more focused testing before merging changes into our main development branch.
Setting Up Route Files
First, let's create the necessary router files to organize our API endpoints. We'll create separate files for session-related routes and user-related routes.
Creating the Session Router
Create a file called session.js in the backend/routes/api folder to handle session-related routes:
// backend/routes/api/session.js
const express = require('express');
const router = express.Router();
module.exports = router;
Creating the Users Router
Create a file called users.js in the backend/routes/api folder to handle user-related routes:
// backend/routes/api/users.js
const express = require('express');
const router = express.Router();
module.exports = router;
Connecting the Routers
Now, let's update the backend/routes/api/index.js file to connect these routers to our API:
// backend/routes/api/index.js
const router = require('express').Router();
const sessionRouter = require('./session.js');
const usersRouter = require('./users.js');
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);
router.use('/session', sessionRouter);
router.use('/users', usersRouter);
router.post('/test', (req, res) => {
res.json({ requestBody: req.body });
});
module.exports = router;
Note that we're connecting the restoreUser middleware before connecting our routers. This ensures that the req.user property is set for all routes, which is essential for our authentication logic.
Implementing Login Functionality
Creating a Login Feature Branch
Let's start by creating a feature branch for the login functionality:
git checkout dev
git checkout -b login
Implementing the Login Route
Update the backend/routes/api/session.js file to include the login route:
// backend/routes/api/session.js
const express = require('express');
const { Op } = require('sequelize');
const bcrypt = require('bcryptjs');
const { setTokenCookie, restoreUser } = require('../../utils/auth');
const { User } = require('../../db/models');
const router = express.Router();
// Log in
router.post('/', async (req, res, next) => {
const { credential, password } = req.body;
// Find the user by either username or email
const user = await User.unscoped().findOne({
where: {
[Op.or]: {
username: credential,
email: credential
}
}
});
// Check if user exists and password is correct
if (!user || !bcrypt.compareSync(password, user.hashedPassword.toString())) {
const err = new Error('Login failed');
err.status = 401;
err.title = 'Login failed';
err.errors = { credential: 'The provided credentials were invalid.' };
return next(err);
}
// Create a safe user object (without hashedPassword)
const safeUser = {
id: user.id,
email: user.email,
username: user.username,
};
// Set the JWT cookie
await setTokenCookie(res, safeUser);
// Return the user information
return res.json({
user: safeUser
});
});
module.exports = router;
Let's break down what this login route does:
- It extracts the
credential(which can be either a username or email) andpasswordfrom the request body. - It finds a user with a matching username or email using Sequelize's
Op.oroperator. - It calls
User.unscoped()to temporarily remove the default scope, which allows us to access thehashedPasswordfield that's normally excluded. - It checks if a user was found and if the provided password matches the stored hashed password.
- If either check fails, it creates an error and passes it to the next error-handling middleware.
- If the credentials are valid, it creates a safe user object (without sensitive information like the hashed password).
- It calls
setTokenCookieto set a JWT cookie containing the user's information. - Finally, it returns a JSON response with the user's information.
Testing the Login Route
Now, let's test the login route to ensure it's working correctly. Start your server if it's not already running:
cd backend
npm start
First, get a CSRF token by navigating to http://localhost:8000/api/csrf/restore. Then, open your browser's developer console and use fetch to test the login route.
Test logging in with a username:
fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ credential: 'Demo-lition', password: 'password' })
}).then(res => res.json()).then(data => console.log(data));
Test logging in with an email:
fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ credential: 'demo@user.io', password: 'password' })
}).then(res => res.json()).then(data => console.log(data));
Test with invalid credentials:
fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ credential: 'Demo-lition', password: 'wrongpassword' })
}).then(res => res.json()).then(data => console.log(data));
If everything is working correctly, you should see a successful login response for the first two requests (with a token cookie set) and an error response for the third request.
Committing and Merging the Login Branch
Once you've confirmed that the login functionality is working, commit your changes and merge them into the dev branch:
git add .
git commit -m "Add User login backend endpoint"
git checkout dev
git pull origin dev # If collaborating with others
git merge login
git push origin dev
Implementing Logout Functionality
Creating a Logout Feature Branch
Let's create a new feature branch for the logout functionality:
git checkout dev
git checkout -b logout
Implementing the Logout Route
Update the backend/routes/api/session.js file to include the logout route:
// backend/routes/api/session.js
// ... existing code
// Log out
router.delete('/', (_req, res) => {
res.clearCookie('token');
return res.json({ message: 'success' });
});
module.exports = router;
The logout route is much simpler than the login route. It simply clears the JWT token cookie and returns a success message. Notice that we use an underscore prefix for the _req parameter to indicate that it's not used in the function, which is a common convention.
Testing the Logout Route
Let's test the logout route to ensure it's working correctly:
fetch('/api/session', {
method: 'DELETE',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
}
}).then(res => res.json()).then(data => console.log(data));
If everything is working correctly, you should see { message: 'success' } in the console, and the token cookie should disappear from your browser's cookies.
Committing and Merging the Logout Branch
Once you've confirmed that the logout functionality is working, commit your changes and merge them into the dev branch:
git add .
git commit -m "Add User logout backend endpoint"
git checkout dev
git pull origin dev
git merge logout
git push origin dev
Implementing Signup Functionality
Creating a Signup Feature Branch
Let's create a new feature branch for the signup functionality:
git checkout dev
git checkout -b signup
Implementing the Signup Route
Update the backend/routes/api/users.js file to include the signup route:
// backend/routes/api/users.js
const express = require('express');
const bcrypt = require('bcryptjs');
const { setTokenCookie, requireAuth } = require('../../utils/auth');
const { User } = require('../../db/models');
const router = express.Router();
// Sign up
router.post('/', async (req, res) => {
const { email, password, username } = req.body;
// Hash the password
const hashedPassword = bcrypt.hashSync(password);
// Create a new user
const user = await User.create({ email, username, hashedPassword });
// Create a safe user object (without hashedPassword)
const safeUser = {
id: user.id,
email: user.email,
username: user.username,
};
// Set the JWT cookie
await setTokenCookie(res, safeUser);
// Return the user information
return res.json({
user: safeUser
});
});
module.exports = router;
Let's break down what this signup route does:
- It extracts the
email,password, andusernamefrom the request body. - It hashes the password using bcrypt for secure storage.
- It creates a new user in the database with the provided information.
- It creates a safe user object (without sensitive information like the hashed password).
- It calls
setTokenCookieto set a JWT cookie containing the user's information. - Finally, it returns a JSON response with the user's information.
Testing the Signup Route
Let's test the signup route to ensure it's working correctly:
fetch('/api/users', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({
email: 'spidey@spider.man',
username: 'Spidey',
password: 'password'
})
}).then(res => res.json()).then(data => console.log(data));
If everything is working correctly, you should see a JSON response with the new user's information, and a token cookie should be set in your browser.
Now, try to test some validation errors by trying to create a user with an existing email or username:
fetch('/api/users', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({
email: 'demo@user.io', // This email already exists
username: 'NewUser',
password: 'password'
})
}).then(res => res.json()).then(data => console.log(data));
You should receive a Sequelize validation error because the email is already in use.
Committing and Merging the Signup Branch
Once you've confirmed that the signup functionality is working, commit your changes and merge them into the dev branch:
git add .
git commit -m "Add User signup backend endpoint"
git checkout dev
git pull origin dev
git merge signup
git push origin dev
Implementing Get Session User Functionality
Creating a Get Session Feature Branch
Let's create a new feature branch for the get session user functionality:
git checkout dev
git checkout -b get-session
Implementing the Get Session User Route
Update the backend/routes/api/session.js file to include the get session user route:
// backend/routes/api/session.js
// ... existing code
// Restore session user
router.get('/', (req, res) => {
const { user } = req;
if (user) {
const safeUser = {
id: user.id,
email: user.email,
username: user.username,
};
return res.json({
user: safeUser
});
} else return res.json({ user: null });
});
module.exports = router;
This route is quite simple. It checks if there's a user on the request object (which would have been set by the restoreUser middleware). If there is, it returns a safe version of the user object. If not, it returns { user: null }.
Testing the Get Session User Route
Let's test the get session user route to ensure it's working correctly. First, make sure you're logged in by using the login route. Then, test the get session user route:
fetch('/api/session')
.then(res => res.json())
.then(data => console.log(data));
If you're logged in, you should see your user information. If you're not logged in, you should see { user: null }.
Committing and Merging the Get Session Branch
Once you've confirmed that the get session user functionality is working, commit your changes and merge them into the dev branch:
git add .
git commit -m "Add a backend endpoint to get the current user session"
git checkout dev
git pull origin dev
git merge get-session
git push origin dev
Understanding the Authentication Flow
Now that we've implemented all the authentication routes, let's take a step back and understand how they work together to create a complete authentication system.
The Authentication Flow
The authentication flow in our application follows these steps:
- Signup: A new user provides their email, username, and password. The password is hashed, and a new user record is created in the database. A JWT containing the user's information is set as a cookie, and the user is considered logged in.
- Login: An existing user provides their credentials (username or email) and password. The application verifies these against the database. If valid, a JWT containing the user's information is set as a cookie, and the user is considered logged in.
- Session Restoration: On subsequent requests, the
restoreUsermiddleware checks for a valid JWT cookie. If found, it retrieves the corresponding user from the database and attaches it to the request object, making it available to route handlers. - Session Check: The
GET /api/sessionroute can be used to check if a user is currently logged in. It returns the user's information if they are, ornullif they're not. - Logout: When a user logs out, the JWT cookie is cleared, and the user is no longer considered logged in.
- Protected Routes: Routes that should only be accessible to logged-in users can use the
requireAuthmiddleware, which checks if a user is attached to the request and returns an error if not.
JWT-based Authentication
Our authentication system uses JSON Web Tokens (JWT) to maintain user sessions. This approach has several advantages:
- Statelessness: The server doesn't need to store session information in memory or a database. All the necessary information is contained in the token itself.
- Security: The token is signed with a secret key known only to the server, making it tamper-proof.
- Efficiency: Token verification is a simple cryptographic operation that doesn't require database lookups.
The JWT is stored in an HTTP-only cookie, which helps protect against cross-site scripting (XSS) attacks because JavaScript code can't access HTTP-only cookies.
Real-world Considerations
In a real-world application, you might want to add additional features to this basic authentication system:
- Email verification: Requiring users to verify their email address before they can use the application.
- Password reset: Allowing users to reset their password if they forget it.
- Multi-factor authentication: Adding an additional layer of security beyond just a password.
- Account lockout: Temporarily locking an account after a certain number of failed login attempts to prevent brute-force attacks.
- Session management: Allowing users to view and manage their active sessions across different devices.
These features would build upon the foundation we've established in this phase.
Common Issues and Troubleshooting
CSRF Token Issues
One common issue is getting a CSRF token error when making POST, PUT, DELETE, or PATCH requests. This can happen if:
- The XSRF-TOKEN cookie is not set.
- The value of the XSRF-TOKEN cookie is not included in the request headers.
- The value in the header doesn't match the value in the cookie.
Always make sure to include the XSRF-TOKEN value in your request headers for non-GET requests:
fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
},
body: JSON.stringify({ /* request body */ })
});
Authentication Issues
If you're having issues with authentication, check the following:
- Is the user being created correctly in the database?
- Is the password being hashed correctly?
- Is the JWT being set as a cookie?
- Is the
restoreUsermiddleware correctly reading the JWT and finding the user?
You can use console.log statements to debug these issues, or use the browser's developer tools to inspect cookies and network requests.
Database Issues
If you're having issues with the database, check the following:
- Are your database migrations applying correctly?
- Are your model validations working as expected?
- Are you handling database errors correctly in your route handlers?
You can use sqlite3 db/dev.db to directly inspect your database and verify that data is being stored correctly.
Next Steps
Now that you have implemented the core authentication routes for your application, you are ready to move on to Phase 5: Validating the Request Body. In that phase, you'll:
- Add validation to ensure that user inputs meet your requirements
- Create a reusable validation middleware
- Apply validation to your login and signup routes
These validations will help improve the security and reliability of your application by ensuring that only valid data is processed.