This content originally appeared on DEV Community and was authored by Dejan Samardzija
DISCLAIMER: This is not "the best way" or "should do" blog post, instead I will just try to share my experience and what I find to work out.
The goal is to create an application that will implement session-based authentication in Node.js using the Express.js framework.
The application is available on the following link Authentication App, and the complete code is available on the GitHub repository.
Content:
- Authentication
- Basic application setup
- Database
- Register and login functionality
- Session-based authentication
- Logout functionality
- Protecting routes and checking the current user
1. Authentication
It is important to distinguish between authentication and authorization. Authentication is the process of verifying the identity of the user 401 Unauthorized and authorization dictates what the user can see and do when it is authenticated 403 Forbidden.
The application uses single-factor authentication based on something the user knows (password). This type of authentication is less secure than two-factor authentication and multi-factor authentication, but it is easier to implement.
To overcome the stateless nature of HTTP requests, there is two main options session-based authentication (stateful, state is stored in the server memory) and token-based authentication (stateless, state is stored inside the token on the client-side).
This application uses session-based authentication.
Session-based authentication flow:
- The user submits the login form and sends credentials (username and password) to the server,
- The server checks credentials in the database,
- If credentials are good, the server creates the session and store it in the database,
- The server sends the cookie with session ID back to the user,
- The user sends the cookie with each request,
- Server validate session ID against session in the database and grants access,
- When the user logout, the server destroys the session.
The authentication process itself must provide different features, such as email verification, password reset, persistent login, account lockout... but in this case, the focus is on the basic functionality of the register, login and logout process.
2. Basic application setup
In the terminal create a new directory, navigate in it and initialize the project.
~$ mkdir Auth-App
~$ cd Auth-App
~/Auth-App$ npm init -yes
After that install project dependencies.
~/Auth-App$ npm install express ejs dotenv sqlite3 express-validator express-session connect-sqlite3 bcrypt
For those unfamiliar with individual packages, below is a brief explanation of why they are used.
Express as the foundation of the project for fast and easy web application development, ejs as a template engine, dotenv for managing environment variables, sqlite3 for interaction with the SQLite database, express-validator to validate users entries, express-session for session management, connect-sqlite3 to store sessions into SQLite database and bcrypt for hashing passwords.
The application is based on the MVC design pattern and has the following structure.
The main entry point of the application is the app.js file and it is located in the root directory.
app.js
// Import modules
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import appRoutes from './routes/appRoutes.js';
// Create express application
const app = express();
// Listen for connections
app.listen(8080,() => {
console.log(`Server started and listening at http://localhost:8080`);
});
// __filename and __dirname common JS variables are not available in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Set view engine and views directory
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Serve static files
app.use(express.static(path.join(__dirname, 'public')));
// Parsing incoming data
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// Aplication routes
app.use(appRoutes);
Node.js doesn’t support ES6 imports directly, and any attempt to use them will result in an error.
SyntaxError: Cannot use import statement outside a module
To enable them, it is necessary to add “type”: "module" in the package.json file.
appRoutes.js
// Imoport modules
import express from 'express';
import * as appController from "../controllers/appControllers.js";
// Create route handler
const router = express.Router();
// Index
// GET
router.get('/', appController.get_index);
// Register
// GET
router.get('/register', appController.get_register);
// POST will be defined later
// Login
// GET
router.get('/login', appController.get_login);
// POST will be defined later
// Logout
// POST will be defined later
// Books
//GET
router.get('/books', appController.get_books);
// Export router
export default router;
appController.js
// Index controller
function get_index (request, response) {
response.render('index');
};
// Register controllers
// GET
function get_register (request, response) {
response.render('register');
};
// post_register function will be defined later
// Login controllers
// GET
function get_login (request, response) {
response.render('login');
};
// post_login function will be defined later
// Books controller
function get_books (request, response) {
response.render('books');
};
// Logout controller
// post_logout function will be defined later
// Export controllers
export {
get_index,
get_register,
get_login,
get_books
};
Currently, the Controller part of the MVC design pattern provides just simple functions to the application router and renders the appropriate views to handle GET requests from the user.
The frontend of the project is written with help of the Bootstrap 5 framework.
Partials header.ejs and footer.ejs contain elementary things (they can be seen in the Github repository) like CDN links to Bootstrap 5 CSS and JS, links to custom CSS (used to override some default Bootstrap 5 styles), and JS (used to switch input element attribute "type" between password and text, so the user can see or hide the password) files.
index.ejs, register,ejs, login.ejs and books.ejs are also very simple and in essence, they produce the following result.
3. Database
In the database directory with terminal or DB Browser for SQLite create the database users.db where user data will be located. Then create the users table and populate it with one user.
~/Auth-App$ cd database
~/Auth-App/database$ sqlite3 users
~/Auth-App/database$ sqlite3
sqlite> CREATE TABLE users(
...> UserID INTEGER NOT NULL UNIQUE,
...> Email TEXT NOT NULL UNIQUE,
...> Password TEXT NOT NULL,
...> PRIMARY KEY("UserID"));
sqlite> INSERT INTO Users VALUES ("1", "name@email.com", "12345678");
sqlite> .quit;
Interaction with the database is defined in the db.js file.
db.js
// Load modules
import sqlite3 from 'sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
// __filename and __dirname common JS variables are not available in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Connect with SQLite database
const db_path = path.join(__dirname, '../database','users');
database = new sqlite3.Database(db_path, sqlite3.OPEN_READWRITE, error => {
if (error) {
console.error(error.message);
}
console.log('Successfully connected to the database);
});
// Export database object
export default database;
4. Register and login functionality
A few things need to be taken into account.
There will be two types of users:
- Visitors have access only to the index page,
- Logged-in users have access to the books page also.
When registering or logging-in, the user must enter valid credentials:
- Email in the appropriate format,
- Password with a minimum length of 8 characters,
and if something is wrong Errors must be shown.
When registering the email address must be unique (checking user entry against the database), If it is not unique, the error must be shown.
The password must be hashed before saving it to the database.
Register functionality
The express-validator package is used for data validation.
appValidator.js
// Load modules
import { body } from 'express-validator';
// Validate user input
function userValidation() {
return [
// Validate email format
body('email')
.isEmail()
.withMessage('Must be a valid email address.'),
// Validate password
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters long.'),
// Validate passwords match
body('passwordConfirm')
.custom((value, { req }) => {
if (value === req.body.password) {
return true;
} else {
return false;
}
})
.withMessage('Those passwords do not match. Try again.')
];
};
// Export userValidation function
export {
userValidation
};
Validation middleware is simply added to the post method for the register route and now if there are some validation errors they will be available using the validationResult method in the appControlers.js file.
appRoutes.js
...
// Register
// GET
router.get('/register', appController.get_register);
router.post('/register', userValidation(), appController.post_register);
...
appControllers.js
import * as appModel from "../models/appModels.js";
import { validationResult } from 'express-validator';
import bcrypt from 'bcrypt';
// The cost factor for hashing password
const saltRounds = 10;
...
// Register controllers
// GET
function get_register (request, response) {
response.render('register');
};
// POST
function post_register (request, response) {
// Get user input
const { email, password, passwordConfirm } = request.body;
// Check for validation errors
const validationErrors = validationResult(request);
console.log(validationErrors);
if (!validationErrors.isEmpty()) {
// If validation errors exist
// Render register view again and and show them
return response.render('register', { email, password, passwordConfirm, validationErrors: validationErrors.mapped() });
} else {
// If there are no validation errors
// Check in database is email already exsist
appModel.findUser(email, (result) => {
if (result != undefined) {
// If there is already a user with that email
const conflictError = 'User with this email already exsist.';
console.log(conflictError);
response.render('register', { email, password, passwordConfirm, conflictError })
} else {
// If the email does not exist in the database
// Hash password
bcrypt.hash(password, saltRounds).then(function(hashedPassword) {
// Save the user into database
appModel.registerUser(email, hashedPassword, (result) => {
console.log(result);
response.redirect('/login');
});
});
};
});
};
};
// Export controllers
export {
get_index,
get_register,
post_register,
get_login,
get_books
};
Models have two simple functions: finding the user by email and creating a new one.
appModels.js
// Load modules
import { appDatabase } from '../database/db.js';
// Find user by email
function findUser (email, callback) {
const sql = 'SELECT * FROM Users WHERE Email = ?';
appDatabase.get(sql, [email], (error, row) => {
if (error) {
callback(error.message);
} else {
callback(row);
};
});
};
// Insert a new user into the database
function registerUser (email, password, callback) {
const sql = 'INSERT INTO Users (Email, Password) VALUES (?, ?)';
appDatabase.run(sql, [email, password], (error, row) => {
if (error) {
callback(error.message);
} else {
const successMessage = 'User registration successful.';
callback(successMessage);
};
});
};
// Export functions
export {
registerUser,
findUser
};
The register.ejs file checks the availability of possible errors (validationErrors, conflictError) using ejs syntax.
register.ejs
<%- include('./partials/header.ejs') %>
<div class="container">
<main class="h-100 mt-5 d-flex flex-column justify-content-center align-items-center">
<form action="/register" method="post" novalidate>
<h1 class="h3 mb-3 fw-normal text-center">Register</h1>
<div class="form-floating mb-3">
<input type="email" class="form-control" id="email" name="email" placeholder="name@example.com" <% if (typeof email != "undefined" && email) { %> value="<%= email %>"<% } %> novalidate>
<label for="email">Email address</label>
<% if (typeof validationErrors != "undefined" && validationErrors.email) { %>
<div class="text-danger text-left mb-3"><%= validationErrors.email.msg %></div>
<% } %>
<% if (typeof conflictError != "undefined" && conflictError) { %>
<div class="text-danger text-left mb-3"><%= conflictError %></div>
<% } %>
</div>
<div class="form-floating mb-3">
<input type="password" class="form-control" id="password" name="password" placeholder="Password" <% if (typeof password != "undefined" && password) { %> value="<%= password %>"<% } %> novalidate>
<label for="password">Password</label>
<% if (typeof validationErrors != "undefined" && validationErrors.password) { %>
<div class="text-danger text-left mb-3"><%= validationErrors.password.msg %></div>
<% } %>
</div>
<div class="form-floating mb-3">
<input type="password" class="form-control" name="passwordConfirm" id="passwordConfirm" placeholder="Password" <% if (typeof passwordConfirm != "undefined" && passwordConfirm) { %> value="<%= passwordConfirm %>"<% } %> novalidate>
<label for="passwordConfirm">Confirm Password</label>
<% if (typeof validationErrors != "undefined" && validationErrors.passwordConfirm) { %>
<div class="text-danger text-left mb-3"><%= validationErrors.passwordConfirm.msg %></div>
<% } %>
</div>
<div class="my-3">
<input class="form-check-input" type="checkbox" value="" id="showPasswords">
<label class="form-check-label" for="showPasswords">
Show passwords
</label>
</dvi>
<button class="w-100 btn btn-lg btn-dark my-3 btn-submit" type="submit">Register</button>
<a href="/login">You already have an account?</a>
</form>
</main>
</div>
<%- include('./partials/footer.ejs') %>
Login functionality
The login functionality of the app is very similar to registration functionality.
appRoutes.js
...
// Login
// GET
router.get('/login', appController.get_login);
router.post('/login', appController.post_login);
...
appControllers.js
...
// Login controllers
// GET
function get_login (request, response) {
response.render('login');
};
// POST
function post_login (request, response) {
// Get user inputs
const { email, password } = request.body;
// Check in database is email already exsist
appModel.findUser(email, (result) => {
if (result != undefined) {
// If there is already a user with that email
// Load hashed password and compare it with entered password
const dbPassword = result.Password;
bcrypt.compare(password, dbPassword, function(err, result) {
if (result == true) {
// If passwords match
request.session.isAuth = true;
request.session.userEmail = email;
console.log('Login successful.')
response.redirect('/books');
} else {
// If passwords do not match
const conflictError = 'User credentials are not valid.';
console.log(conflictError);
response.render('login', { email, password, conflictError });
}
});
} else {
// A user with that email address does not exists
const conflictError = 'User credentials are not valid.';
console.log(conflictError);
response.render('login', { email, password, conflictError });
};
});
};
...
// Export controllers
export {
...
post_login,
get_login,
...
};
This is the current status of the app.
5. Session-based authentication
Session middleware will be defined within the app.js file.
app.js
// Imoport modules
...
import session from 'express-session';
import connectSqlite from 'connect-sqlite3';
import dotenv from 'dotenv/config'
// Create express application
const app = express();
// Session stuff
const SQLiteStore = connectSqlite(session);
app.use(session({
store: new SQLiteStore({
dir: './database/',
db:'sessions',
table:'sessions'}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 1000 * 60 * 60, // 1 hour
sameSite: true
}
}));
...
The store defines what will be used as a storage option and where sessions will be stored, in this case, the SQLite database sessions with table sessions will be created in the database directory.
The secret option is a salt for the hash and should be a random string of characters, and changed periodically. In this case, it is defined in the .env file:
SESSION_SECRET = Some very random string of characters
The resave option by default forces the session to be saved back to the session database, even if the session was never modified during the request, so it will be set to false.
To prevent a lot of empty session objects from being stored in the session database saveUninitialized will be set to false.
The cookie option maxAge simply defines how long it will take for a cookie to become invalid in milliseconds and sameSite will set cookie restricted to a first-party or same-site context.
Part of the post_login controller will also change. So in case, the user logs in with valid credentials, isAuth, and userEmail key-value pairs will be added in the session.
appControllers.js
...
// If passwords match
request.session.isAuth = true;
request.session.userEmail = email;
console.log(request.session);
console.log('User login successful.')
response.redirect('/books');
...
As can be seen in the following video, after the user logs in, a cookie appears in the browser, just like in the sessions database (which is automatically created using express-session) and contains the same id (sdi), as well as other defined things like isAuth, userEmail...
6. Logout functionality
Logout functionality is very easy to implement using the destroy method.
appControllers.js
...
// Logout controller
function post_logout (request, response) {
request.session.destroy((error) => {
if (error) throw error;
console.log('User logout.');
response.redirect('/');
});
};
...
// Export controllers
export {
...
post_logout,
...
};
appRoutes.js
...
// Logout
router.post('/logout', appController.post_logout);
...
header.ejs
...
<li class="nav-item">
<form action="/logout" method="post">
<button class="nav-link nav-button">Logout</button>
</form>
</li>
...
7. Protecting routes and checking the current user
Now is the time to protect certain things from users who are not logged in because at this point they can access the same things as logged-in users.
appMiddlewares.js
// User is not authenticated
function isNotAuth (request, response, next) {
if (request.session.isAuth) {
next();
} else {
response.status(401).render('401');
}
};
// User is authenticated
function isAuth (request, response, next) {
if (request.session.isAuth) {
response.redirect('/books');
} else {
next();
}
};
// Current user
function currentUser (request, response, next) {
if (request.session.userEmail) {
response.locals.userEmail = request.session.userEmail;
next();
} else {
response.locals.userEmail = null;
next();
}
};
export {
isNotAuth,
isAuth,
currentUser
};
The isNotAuth function checks if the user is authorized and if not redirects him to the 401.ejs page and it makes sense to use it on the /books route.
If the user is already logged in, it makes no sense to have access to routes /register and /login.
The currentUser function in response.locals saves the user's email from the session.
appRouter.js
// Imoport modules
import express from 'express';
import * as appController from '../controllers/appControllers.js';
import { userValidation } from '../validation/appValidation.js';
import { isNotAuth, isAuth, currentUser } from '../middlewares/appMiddlewares.js';
// Create route handler
const router = express.Router();
// Index
// GET
router.get('/', currentUser, appController.get_index);
// Register
// GET
router.get('/register', isAuth, currentUser, appController.get_register);
router.post('/register', isAuth, currentUser, userValidation(), appController.post_register);
// Login
// GET
router.get('/login', isAuth, currentUser, appController.get_login);
router.post('/login', isAuth, currentUser, appController.post_login);
// Logout
router.post('/logout', currentUser, appController.post_logout);
// Books
//GET
router.get('/books', isNotAuth, currentUser, appController.get_books);
// Export router
export default router;
Now in header.ejs it is necessary to change the navigation menu so that not logged-in users see the register and login links, and logged-in users their email and logout link.
header.ejs
...
<div class="collapse navbar-collapse me-auto" id="navbarSupportedContent">
<ul class="navbar-nav mb-2 mb-lg-0 ms-auto">
<% if (typeof userEmail != "undefined" && userEmail) { %>
<li class="nav-item">
<a class="nav-link"><%= userEmail %></a>
</li>
<li class="nav-item">
<form action="/logout" method="post">
<button class="nav-link nav-button">Logout</button>
</form>
</li>
<% } else { %>
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/register">Register</a>
</li>
<% } %>
</ul>
</div>
...
For all routes that do not exist a simple 404.ejs page will be rendered.
appControllers.js
...
function page_not_found (request, response) {
response.status(404).render('404');
};
...
// Export controllers
export {
...
page_not_found
};
appRoutes.js
...
// 404 page
router.get('*', appController.page_not_found);
...
And that's it for now. In one of the future posts I will try to implement other features that would make the authentication process more complete.
This content originally appeared on DEV Community and was authored by Dejan Samardzija
Dejan Samardzija | Sciencx (2021-10-31T15:32:01+00:00) Simple App with session-based authentication in Express.js. Retrieved from https://www.scien.cx/2021/10/31/simple-app-with-session-based-authentication-in-express-js/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.
